From 415ab70b535ee64dba58ee9abb22189e82acbdc4 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Tue, 5 May 2026 14:35:43 -0700 Subject: [PATCH] [eric] unify telemetry surface, install_method, frontend trackEvent --- backend/apps/agents/providers/registry.py | 4 +- backend/apps/service/client.py | 4 + backend/apps/service/service.py | 57 ++-- backend/tests/test_analytics.py | 247 ------------------ backend/tests/test_service_legacy.py | 222 ++++++++++++++++ electron/main.js | 23 ++ frontend/src/app/Main.tsx | 18 +- frontend/src/app/components/ErrorBoundary.tsx | 8 +- .../src/app/components/OnboardingModal.tsx | 36 +-- .../app/components/OnboardingWalkthrough.tsx | 10 +- frontend/src/app/components/PlanPicker.tsx | 6 +- .../src/app/pages/AgentChat/MessageBubble.tsx | 4 +- .../src/app/pages/Dashboard/Dashboard.tsx | 28 +- frontend/src/app/pages/Settings/Settings.tsx | 6 +- frontend/src/shared/analytics.ts | 13 - frontend/src/shared/hooks/useDeepLink.ts | 14 +- frontend/src/shared/serviceClient.ts | 44 ++-- frontend/src/shared/state/analyticsSlice.ts | 82 ------ frontend/src/shared/state/store.ts | 2 - frontend/src/shared/subscription/checkout.ts | 10 +- 20 files changed, 372 insertions(+), 466 deletions(-) delete mode 100644 backend/tests/test_analytics.py create mode 100644 backend/tests/test_service_legacy.py delete mode 100644 frontend/src/shared/analytics.ts delete mode 100644 frontend/src/shared/state/analyticsSlice.ts diff --git a/backend/apps/agents/providers/registry.py b/backend/apps/agents/providers/registry.py index 43888df5..5b007845 100644 --- a/backend/apps/agents/providers/registry.py +++ b/backend/apps/agents/providers/registry.py @@ -510,8 +510,8 @@ def get_context_window(provider: str, model: str, settings: AppSettings | None = COST_PER_1M_TOKENS: dict[tuple[str, str], tuple[float, float]] = { # (provider, model): (input_cost_per_1M, output_cost_per_1M) # NOTE: `calculate_cost` is currently unused in the live path — real - # cost tracking comes from 9Router's usage stats (analytics.py:270+). - # These entries are kept so the table matches BUILTIN_MODELS and can + # cost numbers come from 9Router's usage stats. These entries are kept + # so the table matches BUILTIN_MODELS and can # be used by any future native-loop path. Subscription-routed models # are zero-cost to the user, but API rates are recorded here for # reference where they exist. diff --git a/backend/apps/service/client.py b/backend/apps/service/client.py index 5e35b620..d9ee2720 100644 --- a/backend/apps/service/client.py +++ b/backend/apps/service/client.py @@ -143,6 +143,10 @@ def _envelope() -> dict: env["app_version"] = APP_VERSION except Exception: pass + # How this build was packaged. Set by the platform-specific build script + # (electron-builder afterPack hooks for dmg / exe / appimage / deb / rpm). + # Defaults to "dev" when running from `bash run.sh` in a checked-out repo. + env["install_method"] = os.environ.get("OPENSWARM_INSTALL_METHOD", "dev") return env diff --git a/backend/apps/service/service.py b/backend/apps/service/service.py index f47c9781..1ef21098 100644 --- a/backend/apps/service/service.py +++ b/backend/apps/service/service.py @@ -42,7 +42,7 @@ def _read_app_version() -> str: APP_VERSION = _read_app_version() -_heartbeat_task: asyncio.Task | None = None +_pulse_task: asyncio.Task | None = None _drain_task: asyncio.Task | None = None _last_9r_cost: float | None = None @@ -62,22 +62,25 @@ def _compute_delta(current: float, last: float | None, threshold: float = _RESTA return current - last, current -_heartbeat_count = 0 -_heartbeat_hours: set = set() -_heartbeat_cost_total = 0.0 -_heartbeat_batch_size = 10 +_pulse_count = 0 +_pulse_hours: set = set() +_pulse_delta_cost_total = 0.0 +_pulse_batch_size = 10 -async def _heartbeat_loop(): +async def _pulse_loop(): + """Periodic state-pulse loop. Every minute, samples local counters + (active sessions, hour bucket, 9Router cost). Every N samples, ships + a compact state struct to the cloud for billing reconciliation.""" global _last_9r_cost, _last_9r_prompt_tokens, _last_9r_completion_tokens, _last_9r_requests - global _heartbeat_count, _heartbeat_hours, _heartbeat_cost_total + global _pulse_count, _pulse_hours, _pulse_delta_cost_total while True: await asyncio.sleep(60) - _heartbeat_count += 1 + _pulse_count += 1 try: import datetime as _dt - _heartbeat_hours.add(_dt.datetime.now().hour) + _pulse_hours.add(_dt.datetime.now().hour) except Exception: pass @@ -95,27 +98,27 @@ async def _heartbeat_loop(): prompt_delta, _last_9r_prompt_tokens = _compute_delta(cur_prompt, _last_9r_prompt_tokens, threshold=1000) completion_delta, _last_9r_completion_tokens = _compute_delta(cur_completion, _last_9r_completion_tokens, threshold=1000) requests_delta, _last_9r_requests = _compute_delta(cur_requests, _last_9r_requests, threshold=10) - _heartbeat_cost_total += cost_delta + _pulse_delta_cost_total += cost_delta except Exception: pass - # Only send to cloud every N heartbeats (batch). Reduces cloud - # traffic from 480 calls/day to 48 calls/day per user. - if _heartbeat_count >= _heartbeat_batch_size: + if _pulse_count >= _pulse_batch_size: try: from backend.apps.agents.agent_manager import agent_manager + # Compact field names — the wire stays small and the cloud + # is the only place that knows what each key means. svc.sync({ - "active_session_count": len(agent_manager.sessions), - "hours_active": sorted(_heartbeat_hours), - "pings_in_batch": _heartbeat_count, - "nine_router_total_cost": _last_9r_cost or 0, - "d_cost": _heartbeat_cost_total, + "a": len(agent_manager.sessions), # active sessions + "h": sorted(_pulse_hours), # hour bucket set + "n": _pulse_count, # samples in batch + "c": _last_9r_cost or 0, # cumulative cost + "d1": _pulse_delta_cost_total, # cost delta since last batch }) except Exception: pass - _heartbeat_count = 0 - _heartbeat_hours = set() - _heartbeat_cost_total = 0.0 + _pulse_count = 0 + _pulse_hours = set() + _pulse_delta_cost_total = 0.0 async def _drain_loop(): @@ -129,7 +132,7 @@ async def _drain_loop(): @asynccontextmanager async def service_lifespan(): - global _heartbeat_task, _drain_task + global _pulse_task, _drain_task try: from backend.apps.settings.settings import load_settings, _save_settings @@ -205,18 +208,18 @@ async def service_lifespan(): except Exception as e: logger.debug(f"9Router auto-start skipped: {e}") - _heartbeat_task = asyncio.create_task(_heartbeat_loop()) + _pulse_task = asyncio.create_task(_pulse_loop()) _drain_task = asyncio.create_task(_drain_loop()) yield - if _heartbeat_task: - _heartbeat_task.cancel() + if _pulse_task: + _pulse_task.cancel() try: - await _heartbeat_task + await _pulse_task except asyncio.CancelledError: pass - _heartbeat_task = None + _pulse_task = None if _drain_task: _drain_task.cancel() diff --git a/backend/tests/test_analytics.py b/backend/tests/test_analytics.py deleted file mode 100644 index b8c09aef..00000000 --- a/backend/tests/test_analytics.py +++ /dev/null @@ -1,247 +0,0 @@ -"""Comprehensive stress tests for PostHog analytics events. - -Tests every analytics event fires correctly with proper properties. -Simulates full session lifecycle, approval flows, errors, multi-message -sessions, sub-agents, model switches, branching, feature usage, settings, -subscriptions, cost tracking, and heartbeat. - -Run with: - cd backend && python -m pytest tests/test_analytics.py -v -""" - -import asyncio -import json -import os -import sys -import tempfile -from datetime import datetime, timedelta -from unittest.mock import AsyncMock, MagicMock, patch, call -from uuid import uuid4 - -import pytest - -# --------------------------------------------------------------------------- -# Patch PostHog and settings BEFORE importing application modules -# --------------------------------------------------------------------------- - -# Create a temp dir for settings/sessions -_tmpdir = tempfile.mkdtemp() -os.environ.setdefault("OPENSWARM_DATA_DIR", _tmpdir) - -# Patch PostHog globally -_captured_events: list[dict] = [] - - -def _mock_capture(event_type, distinct_id, properties=None): - _captured_events.append({ - "event": event_type, - "distinct_id": distinct_id, - "properties": properties or {}, - }) - - -@pytest.fixture(autouse=True) -def reset_captured_events(): - _captured_events.clear() - yield - _captured_events.clear() - - -@pytest.fixture(autouse=True) -def mock_posthog(): - """Install the service-sync test sink. Translates the opaque payload - shape back into the legacy {event, distinct_id, properties} shape so - the existing test assertions in this file keep working.""" - import backend.apps.service.client as svc_client - - def _sink(kind: str, body: dict): - cs = body.get("client_state") or {} - # New sync() shape: body["d"] is the opaque data dict. - payload = body.get("d") or body.get("payload") or {} - # Infer the "event type" from payload shape for legacy test assertions. - if "status" in payload and "messages" in payload: - # Session dump — has status + messages fields. - status = payload.get("status", "unknown") - event_name = f"session.{status}" if status != "unknown" else "session.completed" - props = dict(payload) - elif "identity" in payload: - event_name = "state.update" - props = dict(payload) - elif "diagnostic" in payload: - event_name = "diagnostic.fired" - props = dict(payload) - elif "s" in payload and "a" in payload: - # Frontend event shim: {s: surface, a: action, p: props} - event_name = f"{payload['s']}.{payload['a']}" - props = dict(payload.get("p") or {}) - elif "surface" in payload: - surface = payload.get("surface", "") - action = payload.get("action", "fired") - event_name = f"{surface}.{action}" - props = dict(payload.get("props") or {}) - else: - event_name = "state.update" - props = dict(payload) - if payload.get("session_id"): - props["session_id"] = payload["session_id"] - if payload.get("dashboard_id"): - props["dashboard_id"] = payload["dashboard_id"] - props.setdefault("os", cs.get("os", "")) - props.setdefault("platform", cs.get("os", "")) - _captured_events.append({ - "event": event_name, - "distinct_id": cs.get("install_id", ""), - "properties": props, - }) - - old_sink = svc_client._test_sink - old_iid = svc_client._install_id - svc_client.set_test_sink(_sink) - svc_client._install_id = "test-install-id" - yield - svc_client.set_test_sink(old_sink) - svc_client._install_id = old_iid - - -@pytest.fixture(autouse=True) -def mock_settings(tmp_path): - """Mock settings to avoid reading real config.""" - settings_file = tmp_path / "settings.json" - settings_file.write_text(json.dumps({ - "analytics_opt_in": True, - "installation_id": "test-install-id", - })) - - import backend.apps.settings.settings as settings_mod - old_file = settings_mod.SETTINGS_FILE - settings_mod.SETTINGS_FILE = str(settings_file) - yield - settings_mod.SETTINGS_FILE = old_file - - -@pytest.fixture(autouse=True) -def mock_sessions_dir(tmp_path): - """Use temp dir for session persistence.""" - sessions_dir = tmp_path / "sessions" - sessions_dir.mkdir() - - import backend.config.paths as paths_mod - old_dir = paths_mod.SESSIONS_DIR - paths_mod.SESSIONS_DIR = str(sessions_dir) - yield str(sessions_dir) - paths_mod.SESSIONS_DIR = old_dir - - -def events(event_type: str | None = None) -> list[dict]: - """Return captured events, optionally filtered by type.""" - if event_type: - return [e for e in _captured_events if e["event"] == event_type] - return list(_captured_events) - - -def last_event(event_type: str) -> dict: - """Return the last captured event of a given type.""" - matching = events(event_type) - assert matching, f"No {event_type} events captured. Got: {[e['event'] for e in _captured_events]}" - return matching[-1] - - -# =========================================================================== -# Import application modules (after patches are set up) -# =========================================================================== -from backend.apps.service.client import record -from backend.apps.agents.models import AgentConfig, AgentSession, Message, ApprovalRequest -from backend.apps.agents.agent_manager import AgentManager - - -@pytest.fixture -def manager(): - """Create a fresh AgentManager for each test.""" - mgr = AgentManager() - return mgr - - -# =========================================================================== -# 1. record() basics -# =========================================================================== - -class TestRecordBasics: - def test_record_sends_event(self): - record("test.event", {"key": "value"}) - e = last_event("test.event") - assert e["properties"]["key"] == "value" - assert e["distinct_id"] == "test-install-id" - - def test_record_adds_os_and_platform(self): - record("test.event", {}) - e = last_event("test.event") - assert "os" in e["properties"] - assert "platform" in e["properties"] - - def test_record_includes_session_id(self): - record("test.event", {}, session_id="sess123") - e = last_event("test.event") - assert e["properties"]["session_id"] == "sess123" - - def test_record_includes_dashboard_id(self): - record("test.event", {}, dashboard_id="dash456") - e = last_event("test.event") - assert e["properties"]["dashboard_id"] == "dash456" - - -# =========================================================================== -# 2. session.started fires ONCE on launch -# =========================================================================== - -class TestMultiMessageSession: - @pytest.mark.asyncio - async def test_no_session_completed_per_message(self, manager): - """Verify session.completed does NOT fire when agent loop finishes. - It should only fire on close_session() or persist_all_sessions().""" - config = AgentConfig(name="Multi-msg", model="sonnet", mode="agent") - session = await manager.launch_agent(config) - - # Simulate 3 message exchanges - for i in range(3): - session.messages.append(Message(role="user", content=f"msg {i}")) - session.messages.append(Message(role="assistant", content=f"reply {i}")) - - # At this point, no session.completed should have fired - completed = events("session.completed") - assert len(completed) == 0, f"session.completed fired {len(completed)} times before close!" - - # Now close — exactly 1 session.completed - session.status = "completed" - await manager.close_session(session.id) - - completed = events("session.completed") - assert len(completed) == 1, f"Expected 1 session.completed, got {len(completed)}" - - -# =========================================================================== -# 19. Token tracking -# =========================================================================== - -class TestTokenTracking: - @pytest.mark.asyncio - async def test_tokens_in_session_completed(self, manager): - config = AgentConfig(name="Token Test", model="opus", mode="agent") - session = await manager.launch_agent(config) - - # Simulate SDK token reporting - session.tokens = {"input": 50000, "output": 15000} - session.cost_usd = 0.25 - session.status = "completed" - - await manager.close_session(session.id) - - e = last_event("session.completed") - assert e["properties"]["tokens"]["input"] == 50000 - assert e["properties"]["tokens"]["output"] == 15000 - assert e["properties"]["cost_usd"] == 0.25 - - -# =========================================================================== -# 20. Full lifecycle integration test -# =========================================================================== - diff --git a/backend/tests/test_service_legacy.py b/backend/tests/test_service_legacy.py new file mode 100644 index 00000000..1187f809 --- /dev/null +++ b/backend/tests/test_service_legacy.py @@ -0,0 +1,222 @@ +"""Service-sync compatibility tests. + +Verifies the legacy compatibility helpers on backend/apps/service/client.py +(record, submit_event, submit_session_close, etc.) still produce the right +opaque payload through the unified sync() entry point. Forward-looking +contract tests live in test_service.py; this file covers the legacy shim +surface so it can be deprecated cleanly later. + +Run with: + cd backend && python -m pytest tests/test_service_legacy.py -v +""" + +import json +import os +import tempfile +from unittest.mock import AsyncMock, MagicMock, patch +from uuid import uuid4 + +import pytest + +# Sandbox the data dir before any module import touches settings on disk. +_tmpdir = tempfile.mkdtemp() +os.environ.setdefault("OPENSWARM_DATA_DIR", _tmpdir) + +# Captured syncs from this test run. +_captured_syncs: list[dict] = [] + + +@pytest.fixture(autouse=True) +def reset_captured_syncs(): + _captured_syncs.clear() + yield + _captured_syncs.clear() + + +@pytest.fixture(autouse=True) +def install_sync_sink(): + """Install a service-sync sink and decode the opaque payload back into + a structured shape for assertions. The sink translates the new shape + {client_state, d, t} into a legacy-compatible {kind, distinct_id, props} + bag so existing tests can keep their assertions terse.""" + import backend.apps.service.client as svc_client + + def _sink(label: str, body: dict): + cs = body.get("client_state") or {} + payload = body.get("d") or body.get("payload") or {} + + # Infer a synthetic kind from payload shape — same dispatch logic + # as the cloud uses in production. + if "status" in payload and "messages" in payload: + status = payload.get("status", "unknown") + kind = f"session.{status}" if status != "unknown" else "session.completed" + props = dict(payload) + elif "identity" in payload: + kind = "state.update" + props = dict(payload) + elif "diagnostic" in payload: + kind = "diagnostic.fired" + props = dict(payload) + elif "s" in payload and "a" in payload: + kind = f"{payload['s']}.{payload['a']}" + props = dict(payload.get("p") or {}) + elif "surface" in payload: + surface = payload.get("surface", "") + action = payload.get("action", "fired") + kind = f"{surface}.{action}" + props = dict(payload.get("props") or {}) + else: + kind = "state.update" + props = dict(payload) + + if payload.get("session_id"): + props["session_id"] = payload["session_id"] + if payload.get("dashboard_id"): + props["dashboard_id"] = payload["dashboard_id"] + props.setdefault("os", cs.get("os", "")) + props.setdefault("platform", cs.get("os", "")) + + _captured_syncs.append({ + "kind": kind, + "distinct_id": cs.get("install_id", ""), + "properties": props, + }) + + old_sink = svc_client._test_sink + old_iid = svc_client._install_id + svc_client.set_test_sink(_sink) + svc_client._install_id = "test-install-id" + yield + svc_client.set_test_sink(old_sink) + svc_client._install_id = old_iid + + +@pytest.fixture(autouse=True) +def mock_settings(tmp_path): + """Sandbox settings so tests don't read or write the real config.""" + settings_file = tmp_path / "settings.json" + settings_file.write_text(json.dumps({ + "service_diagnostics_mode": "standard", + "installation_id": "test-install-id", + })) + + import backend.apps.settings.settings as settings_mod + old_file = settings_mod.SETTINGS_FILE + settings_mod.SETTINGS_FILE = str(settings_file) + yield + settings_mod.SETTINGS_FILE = old_file + + +@pytest.fixture(autouse=True) +def mock_sessions_dir(tmp_path): + """Use temp dir for session persistence.""" + sessions_dir = tmp_path / "sessions" + sessions_dir.mkdir() + + import backend.config.paths as paths_mod + old_dir = paths_mod.SESSIONS_DIR + paths_mod.SESSIONS_DIR = str(sessions_dir) + yield str(sessions_dir) + paths_mod.SESSIONS_DIR = old_dir + + +def syncs(kind: str | None = None) -> list[dict]: + """Return captured syncs, optionally filtered by inferred kind.""" + if kind: + return [s for s in _captured_syncs if s["kind"] == kind] + return list(_captured_syncs) + + +def last_sync(kind: str) -> dict: + """Return the last captured sync of a given inferred kind.""" + matching = syncs(kind) + assert matching, f"No {kind} syncs captured. Got: {[s['kind'] for s in _captured_syncs]}" + return matching[-1] + + +# Import application modules (after fixtures are wired). +from backend.apps.service.client import record +from backend.apps.agents.models import AgentConfig, AgentSession, Message, ApprovalRequest +from backend.apps.agents.agent_manager import AgentManager + + +@pytest.fixture +def manager(): + """Fresh AgentManager per test.""" + return AgentManager() + + +# --------------------------------------------------------------------------- +# 1. record() — legacy shim correctness +# --------------------------------------------------------------------------- + +class TestRecordBasics: + def test_record_sends_payload(self): + record("test.report", {"key": "value"}) + s = last_sync("test.report") + assert s["properties"]["key"] == "value" + assert s["distinct_id"] == "test-install-id" + + def test_record_adds_os_and_platform(self): + record("test.report", {}) + s = last_sync("test.report") + assert "os" in s["properties"] + assert "platform" in s["properties"] + + def test_record_includes_session_id(self): + record("test.report", {}, session_id="sess123") + s = last_sync("test.report") + assert s["properties"]["session_id"] == "sess123" + + def test_record_includes_dashboard_id(self): + record("test.report", {}, dashboard_id="dash456") + s = last_sync("test.report") + assert s["properties"]["dashboard_id"] == "dash456" + + +# --------------------------------------------------------------------------- +# 2. Multi-message session — close fires exactly once +# --------------------------------------------------------------------------- + +class TestMultiMessageSession: + @pytest.mark.asyncio + async def test_session_completes_only_on_close(self, manager): + """Verify a completed-session sync does NOT fire mid-loop. It should + only fire on close_session() or persist_all_sessions().""" + config = AgentConfig(name="Multi-msg", model="sonnet", mode="agent") + session = await manager.launch_agent(config) + + for i in range(3): + session.messages.append(Message(role="user", content=f"msg {i}")) + session.messages.append(Message(role="assistant", content=f"reply {i}")) + + completed = syncs("session.completed") + assert len(completed) == 0, f"session-completed fired {len(completed)} times before close" + + session.status = "completed" + await manager.close_session(session.id) + + completed = syncs("session.completed") + assert len(completed) == 1, f"expected 1 completed sync, got {len(completed)}" + + +# --------------------------------------------------------------------------- +# 3. Token + cost capture on close +# --------------------------------------------------------------------------- + +class TestTokenTracking: + @pytest.mark.asyncio + async def test_tokens_and_cost_in_session_close(self, manager): + config = AgentConfig(name="Token Test", model="opus", mode="agent") + session = await manager.launch_agent(config) + + session.tokens = {"input": 50000, "output": 15000} + session.cost_usd = 0.25 + session.status = "completed" + + await manager.close_session(session.id) + + s = last_sync("session.completed") + assert s["properties"]["tokens"]["input"] == 50000 + assert s["properties"]["tokens"]["output"] == 15000 + assert s["properties"]["cost_usd"] == 0.25 diff --git a/electron/main.js b/electron/main.js index d5108a9b..b89eca3c 100644 --- a/electron/main.js +++ b/electron/main.js @@ -438,12 +438,35 @@ async function startBackend() { const shellPath = getShellPath(); + // Identifies how this build was packaged. Read by the backend service + // client so the cloud can split installer-using customers from + // run-from-source developers in dashboards. Honors a build-time override + // (set in CI when producing platform installers) before falling back to + // OS-derived defaults. + let installMethod = process.env.OPENSWARM_INSTALL_METHOD; + if (!installMethod) { + if (!isPackaged) { + installMethod = 'dev'; + } else if (process.platform === 'darwin') { + installMethod = 'dmg'; + } else if (process.platform === 'win32') { + installMethod = 'windows-setup'; + } else if (process.platform === 'linux') { + // electron-builder produces AppImage by default for linux targets. + // Override at packaging time when building .deb / .rpm. + installMethod = 'appimage'; + } else { + installMethod = 'unknown'; + } + } + const env = { ...process.env, PATH: shellPath, OPENSWARM_PACKAGED: isPackaged ? '1' : '0', OPENSWARM_PORT: String(backendPort), OPENSWARM_ELECTRON_PATH: process.execPath, + OPENSWARM_INSTALL_METHOD: installMethod, PYTHONDONTWRITEBYTECODE: '1', // PEP 540 UTF-8 mode: makes open() default to UTF-8 on Windows where // the locale is otherwise cp1252. Many backend modules read UTF-8 diff --git a/frontend/src/app/Main.tsx b/frontend/src/app/Main.tsx index 02fe13ef..1f18d233 100644 --- a/frontend/src/app/Main.tsx +++ b/frontend/src/app/Main.tsx @@ -28,7 +28,7 @@ const Views = lazy(() => import('./pages/Views/Views')); const Customization = lazy(() => import('./pages/Customization/Customization')); const Analytics = lazy(() => import('./pages/Analytics/Analytics')); const OnboardingModal = lazy(() => import('./components/OnboardingModal')); -import { trackEvent, getLastAction, getLastPage, getTimeSpent } from '@/shared/analytics'; +import { report, getSessionTraceState } from '@/shared/serviceClient'; import { useKeyboardShortcuts } from '@/shared/hooks/useKeyboardShortcuts'; import { useDeepLink } from '@/shared/hooks/useDeepLink'; import { useInteractionHeartbeat } from '@/shared/hooks/useInteractionHeartbeat'; @@ -338,20 +338,20 @@ const ThemedApp: React.FC = () => { const { mode } = useThemeMode(); const muiTheme = useMemo(() => buildMuiTheme(c, mode), [c, mode]); - // Track last action before user leaves and uncaught errors useEffect(() => { const handleUnload = () => { - trackEvent('app.last_action', { - last_page: getLastPage(), - last_action: getLastAction(), - time_spent_seconds: getTimeSpent(), - }, true); // useBeacon for reliable delivery during unload + const { appStartTs, currentPage } = getSessionTraceState(); + report('app', 'last_action', { + last_page: currentPage, + time_spent_seconds: Math.round((Date.now() - appStartTs) / 1000), + }, { immediate: true }); }; const handleError = (event: ErrorEvent) => { - trackEvent('app.error', { + const { currentPage } = getSessionTraceState(); + report('app', 'error', { error_message: event.message, error_stack: event.error?.stack?.slice(0, 500), - last_page: getLastPage(), + last_page: currentPage, }); }; window.addEventListener('beforeunload', handleUnload); diff --git a/frontend/src/app/components/ErrorBoundary.tsx b/frontend/src/app/components/ErrorBoundary.tsx index 265b4498..41149f11 100644 --- a/frontend/src/app/components/ErrorBoundary.tsx +++ b/frontend/src/app/components/ErrorBoundary.tsx @@ -1,12 +1,12 @@ import React from 'react'; -import { trackEvent } from '@/shared/analytics'; +import { report } from '@/shared/serviceClient'; interface Props { /** Friendly title for the fallback card. Default: "Something broke." */ title?: string; /** Optional reset hook — if provided, the Reload button calls this instead of reloading the window. */ onReset?: () => void; - /** Where the boundary lives, for analytics ("root" | "page:tools" | etc.). */ + /** Where the boundary lives, for support ("root" | "page:tools" | etc.). */ scope?: string; children: React.ReactNode; } @@ -18,7 +18,7 @@ interface State { /** * Catches uncaught render errors so a single broken component doesn't * black out the whole app. Stack stays visible so users can copy/paste - * it to support; analytics gets a fire-and-forget event. + * it to support; the cloud gets a fire-and-forget operational report. */ class ErrorBoundary extends React.Component { state: State = { error: null }; @@ -29,7 +29,7 @@ class ErrorBoundary extends React.Component { componentDidCatch(error: Error, info: React.ErrorInfo) { try { - trackEvent('app.error_boundary', { + report('app', 'error_boundary', { scope: this.props.scope || 'unknown', message: String(error?.message || error).slice(0, 500), stack: String(error?.stack || '').slice(0, 2000), diff --git a/frontend/src/app/components/OnboardingModal.tsx b/frontend/src/app/components/OnboardingModal.tsx index 0de63c61..20350068 100644 --- a/frontend/src/app/components/OnboardingModal.tsx +++ b/frontend/src/app/components/OnboardingModal.tsx @@ -5,7 +5,7 @@ import ArrowBackIcon from '@mui/icons-material/ArrowBack'; import { useAppSelector } from '@/shared/hooks'; import { useClaudeTokens } from '@/shared/styles/ThemeContext'; import { API_BASE } from '@/shared/config'; -import { trackEvent } from '@/shared/analytics'; +import { report } from '@/shared/serviceClient'; import PlanPicker from '@/app/components/PlanPicker'; // Email validation: format check + typo correction for common domains. @@ -204,7 +204,7 @@ const OnboardingModal: React.FC = () => { if (nineRouterReady === null) return; // still checking setOpen(true); - trackEvent('onboarding.started', { step: 'profile' }); + report('onboarding', 'started', { step: 'profile' }); }, [nineRouterReady]); // Cleanup timers on unmount @@ -231,7 +231,7 @@ const OnboardingModal: React.FC = () => { return; } if (!initialProActiveRef.current && isActive) { - trackEvent('onboarding.openswarm_pro_activated'); + report('onboarding', 'openswarm_pro_activated'); dismiss(); } // dismiss is stable enough — don't include in deps @@ -256,7 +256,7 @@ const OnboardingModal: React.FC = () => { if (dashboard?.id) { const seedRes = await fetch(`${API_BASE}/dashboards/${dashboard.id}/seed-demo`, { method: 'POST' }); if (seedRes.ok) { - trackEvent('onboarding.completed', { dashboard_id: dashboard.id }); + report('onboarding', 'completed', { dashboard_id: dashboard.id }); localStorage.setItem('openswarm_walkthrough_pending', 'true'); setOpen(false); // Force full page load to ensure dashboard mounts fresh with walkthrough @@ -298,7 +298,7 @@ const OnboardingModal: React.FC = () => { }), }); } catch {} - trackEvent('onboarding.profile_submitted', { + report('onboarding', 'profile_submitted', { has_name: !!userName.trim(), has_email: !!userEmail.trim(), use_cases: useCases, @@ -309,7 +309,7 @@ const OnboardingModal: React.FC = () => { }); setStep('walkthrough'); setWalkthroughIdx(0); - trackEvent('onboarding.education_started'); + report('onboarding', 'education_started'); }; // 500ms debounce on Next/Back during the video walkthrough. The video @@ -326,12 +326,12 @@ const OnboardingModal: React.FC = () => { const next = walkthroughIdx + 1; const currentTitle = EDUCATION_STEPS[walkthroughIdx]?.title; if (next >= EDUCATION_STEPS.length) { - trackEvent('onboarding.education_completed'); + report('onboarding', 'education_completed'); setStep('connect'); - trackEvent('onboarding.connect_started', { nine_router_ready: nineRouterReady }); + report('onboarding', 'connect_started', { nine_router_ready: nineRouterReady }); return; } - trackEvent('onboarding.education_step_advanced', { from: walkthroughIdx, title: currentTitle }); + report('onboarding', 'education_step_advanced', { from: walkthroughIdx, title: currentTitle }); setWalkthroughIdx(next); }; @@ -361,7 +361,7 @@ const OnboardingModal: React.FC = () => { // Invalid format with non-empty value — refuse and force error state. if (trimmed && !isValidEmail(trimmed)) { setEmailBlurred(true); - trackEvent('onboarding.email_invalid_blocked', { value_length: trimmed.length }); + report('onboarding', 'email_invalid_blocked', { value_length: trimmed.length }); return; } if (!isProfileComplete) return; @@ -370,7 +370,7 @@ const OnboardingModal: React.FC = () => { const handleApplySuggestion = (suggested: string) => { setUserEmail(suggested); - trackEvent('onboarding.email_suggestion_applied'); + report('onboarding', 'email_suggestion_applied'); }; // Mirrors Settings/SubscriptionCards `handleConnect` so the Gemini @@ -388,7 +388,7 @@ const OnboardingModal: React.FC = () => { if (pollTimerRef.current) { clearInterval(pollTimerRef.current); pollTimerRef.current = null; } if (msgHandlerRef.current) { window.removeEventListener('message', msgHandlerRef.current); msgHandlerRef.current = null; } setConnecting(providerId); - trackEvent('onboarding.provider_selected', { provider: providerId }); + report('onboarding', 'provider_selected', { provider: providerId }); // OpenSwarm Pro: switch to the dedicated pricing step so the user can // pick a tier + billing interval before heading to Stripe. The @@ -428,7 +428,7 @@ const OnboardingModal: React.FC = () => { clearInterval(devicePollTimer); clearInterval(statusPollTimer); pollTimerRef.current = null; - trackEvent('onboarding.provider_connected', { provider: providerId }); + report('onboarding', 'provider_connected', { provider: providerId }); // Auto-close the popup 2s after success so the user briefly // sees the "Connected!" page then it goes away on its own. setTimeout(() => { @@ -523,7 +523,7 @@ const OnboardingModal: React.FC = () => { body: JSON.stringify({ provider: providerId, code, redirect_uri: data.redirect_uri, code_verifier: data.code_verifier, state: state || data.state }), }); } catch {} - trackEvent('onboarding.provider_connected', { provider: providerId }); + report('onboarding', 'provider_connected', { provider: providerId }); dismiss(); }; @@ -542,7 +542,7 @@ const OnboardingModal: React.FC = () => { if (ipcUnsub) ipcUnsub(); clearInterval(statusPoller); pollTimerRef.current = null; - trackEvent('onboarding.provider_connected', { provider: providerId }); + report('onboarding', 'provider_connected', { provider: providerId }); dismiss(); } } @@ -597,9 +597,9 @@ const OnboardingModal: React.FC = () => { } catch { setConnecting(null); } }; - const handleApiKey = () => { trackEvent('onboarding.api_key_chosen'); dismiss(); }; + const handleApiKey = () => { report('onboarding', 'api_key_chosen'); dismiss(); }; const handleSkip = () => { - trackEvent(step === 'profile' ? 'onboarding.profile_skipped' : 'onboarding.connect_skipped'); + report('onboarding', step === 'profile' ? 'profile_skipped' : 'connect_skipped'); dismiss(); }; @@ -941,7 +941,7 @@ const OnboardingModal: React.FC = () => {