[eric] unify telemetry surface, install_method, frontend trackEvent

This commit is contained in:
ciregenz
2026-05-05 14:35:43 -07:00
parent 49c649e3d9
commit 415ab70b53
20 changed files with 372 additions and 466 deletions
+2 -2
View File
@@ -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.
+4
View File
@@ -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
+30 -27
View File
@@ -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()
-247
View File
@@ -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
# ===========================================================================
+222
View File
@@ -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
+23
View File
@@ -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
+9 -9
View File
@@ -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);
@@ -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<Props, State> {
state: State = { error: null };
@@ -29,7 +29,7 @@ class ErrorBoundary extends React.Component<Props, State> {
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),
+18 -18
View File
@@ -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 = () => {
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mt: 2 }}>
<Button
onClick={() => { setStep('connect'); trackEvent('onboarding.pricing_back'); }}
onClick={() => { setStep('connect'); report('onboarding', 'pricing_back'); }}
startIcon={<ArrowBackIcon sx={{ fontSize: 14 }} />}
sx={{
textTransform: 'none', fontSize: '0.85rem', fontWeight: 500,
@@ -3,7 +3,7 @@ import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import Button from '@mui/material/Button';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import { trackEvent } from '@/shared/analytics';
import { report } from '@/shared/serviceClient';
export interface WalkthroughStep {
target: string; // data-onboarding="<value>" selector
@@ -93,13 +93,13 @@ const OnboardingWalkthrough: React.FC<Props> = ({ onComplete }) => {
// Track walkthrough start on mount
useEffect(() => {
trackEvent('walkthrough.started');
report('walkthrough', 'started');
}, []);
// Track each step viewed
useEffect(() => {
if (step) {
trackEvent('walkthrough.step_viewed', { step: currentStep, step_name: step.target || 'done' });
report('walkthrough', 'step_viewed', { step: currentStep, step_name: step.target || 'done' });
}
}, [currentStep, step]);
@@ -194,7 +194,7 @@ const OnboardingWalkthrough: React.FC<Props> = ({ onComplete }) => {
const handleNext = useCallback(() => {
if (isLastStep) {
trackEvent('walkthrough.completed', { steps_viewed: currentStep + 1 });
report('walkthrough', 'completed', { steps_viewed: currentStep + 1 });
onComplete();
} else {
setCurrentStep((s) => s + 1);
@@ -216,7 +216,7 @@ const OnboardingWalkthrough: React.FC<Props> = ({ onComplete }) => {
if (!el) return;
const handler = () => {
trackEvent('walkthrough.step_action', { step: currentStep, step_name: step.target });
report('walkthrough', 'step_action', { step: currentStep, step_name: step.target });
setTimeout(() => handleNext(), 300);
};
el.addEventListener('click', handler, { once: true });
+3 -3
View File
@@ -6,7 +6,7 @@ import ToggleButton from '@mui/material/ToggleButton';
import ToggleButtonGroup from '@mui/material/ToggleButtonGroup';
import CheckIcon from '@mui/icons-material/Check';
import CircularProgress from '@mui/material/CircularProgress';
import { trackEvent } from '@/shared/analytics';
import { report } from '@/shared/serviceClient';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import {
subscribeToPlan,
@@ -114,7 +114,7 @@ const PlanPicker: React.FC<PlanPickerProps> = ({
const [pending, setPending] = useState<OpenSwarmPlan | null>(null);
React.useEffect(() => {
trackEvent('subscription.plan_picker_opened', { source, default_plan: defaultPlan ?? 'pro_plus' });
report('subscription', 'plan_picker_opened', { source, default_plan: defaultPlan ?? 'pro_plus' });
}, [source, defaultPlan]);
const handleSubscribe = async (plan: OpenSwarmPlan) => {
@@ -130,7 +130,7 @@ const PlanPicker: React.FC<PlanPickerProps> = ({
const handleIntervalChange = (_: React.MouseEvent<HTMLElement>, next: BillingInterval | null) => {
if (!next) return;
setInterval(next);
trackEvent('subscription.billing_interval_toggled', { source, interval: next });
report('subscription', 'billing_interval_toggled', { source, interval: next });
};
// Typography scale — scaled down in compact mode (MessageBubble modal) but
@@ -1,5 +1,5 @@
import React, { useState, useMemo } from 'react';
import { trackEvent } from '@/shared/analytics';
import { report } from '@/shared/serviceClient';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import IconButton from '@mui/material/IconButton';
@@ -841,7 +841,7 @@ const MessageBubble: React.FC<Props> = React.memo(({ message, editing = false, o
// fire once per cap card. (message.id, kind) keeps it from re-firing on edits.
React.useEffect(() => {
if (openswarmError?.kind === 'cap') {
trackEvent('subscription.rate_limit_hit', { message_id: message.id });
report('subscription', 'rate_limit_hit', { message_id: message.id });
}
}, [message.id, openswarmError?.kind]);
+14 -14
View File
@@ -3,7 +3,7 @@ import { AnimatePresence, motion } from 'framer-motion';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import DashboardHeader from './DashboardHeader';
import { trackEvent } from '@/shared/analytics';
import { report } from '@/shared/serviceClient';
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
import { store } from '@/shared/state/store';
import {
@@ -301,7 +301,7 @@ const DashboardInner: React.FC<DashboardProps> = ({ dashboardId, isActive = true
}, [tickEdgePan]);
const handleCardDragEnd = useCallback((dx: number, dy: number, didDrag: boolean) => {
if (didDrag) trackEvent('dashboard.card_dragged');
if (didDrag) report('dashboard', 'card_dragged');
stopEdgePan();
if (isMultiDragRef.current && didDrag) {
const items = selection.selectedArray()
@@ -343,7 +343,7 @@ const DashboardInner: React.FC<DashboardProps> = ({ dashboardId, isActive = true
const clickTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const handleCardSelect = useCallback((id: string, type: CardType, shiftKey: boolean) => {
trackEvent('dashboard.card_clicked', { card_type: type, shift: shiftKey });
report('dashboard', 'card_clicked', { card_type: type, shift: shiftKey });
if (shiftKey) {
selection.selectCard(id, type, true);
return;
@@ -433,13 +433,13 @@ const DashboardInner: React.FC<DashboardProps> = ({ dashboardId, isActive = true
const handleViewportDoubleClick = useCallback((e: React.MouseEvent) => {
if (e.button !== 0) return;
if (isCardTarget(e.target, e.currentTarget)) return;
trackEvent('dashboard.canvas_double_clicked');
report('dashboard', 'canvas_double_clicked');
canvas.actions.fitToView();
}, [canvas.actions]);
// Double-click a card → always expand + center + zoom (cancels pending collapse from single-click)
const handleCardDoubleClick = useCallback((id: string, type: CardType) => {
trackEvent('dashboard.card_double_clicked', { card_type: type });
report('dashboard', 'card_double_clicked', { card_type: type });
if (clickTimerRef.current) {
clearTimeout(clickTimerRef.current);
clickTimerRef.current = null;
@@ -460,9 +460,9 @@ const DashboardInner: React.FC<DashboardProps> = ({ dashboardId, isActive = true
useEffect(() => {
if (!dashboardId) return;
const startTime = Date.now();
trackEvent('dashboard.opened', { dashboard_id: dashboardId });
report('dashboard', 'opened', { dashboard_id: dashboardId });
return () => {
trackEvent('dashboard.closed', {
report('dashboard', 'closed', {
dashboard_id: dashboardId,
time_spent_seconds: Math.round((Date.now() - startTime) / 1000),
});
@@ -877,7 +877,7 @@ const DashboardInner: React.FC<DashboardProps> = ({ dashboardId, isActive = true
if (tag === 'INPUT' || tag === 'TEXTAREA' || (e.target as HTMLElement)?.isContentEditable) return;
e.preventDefault();
setSearchPaletteOpen(true);
trackEvent('dashboard.search_opened');
report('dashboard', 'search_opened');
};
window.addEventListener('keydown', handleSearch);
return () => window.removeEventListener('keydown', handleSearch);
@@ -1135,7 +1135,7 @@ const DashboardInner: React.FC<DashboardProps> = ({ dashboardId, isActive = true
}
// Expand + navigate to target + bring to front
trackEvent('dashboard.arrow_navigated', { direction, from_card: currentFocused, to_card: target.id });
report('dashboard', 'arrow_navigated', { direction, from_card: currentFocused, to_card: target.id });
if (target.type === 'agent') {
dispatch(expandSession(target.id));
}
@@ -1217,7 +1217,7 @@ const DashboardInner: React.FC<DashboardProps> = ({ dashboardId, isActive = true
selectedBrowserIds?: string[],
) => {
setToolbarOpen(false);
trackEvent('dashboard.agent_created', { mode, model, has_images: !!images?.length, has_context: !!contextPaths?.length, has_browser: !!selectedBrowserIds?.length });
report('dashboard', 'agent_created', { mode, model, has_images: !!images?.length, has_context: !!contextPaths?.length, has_browser: !!selectedBrowserIds?.length });
const draftId = `draft-${Date.now().toString(36)}`;
@@ -1321,7 +1321,7 @@ const DashboardInner: React.FC<DashboardProps> = ({ dashboardId, isActive = true
}, [dispatch, expandedSessionIds, canvas.actions, handleHighlightCard]);
const handleAddBrowser = useCallback(() => {
trackEvent('dashboard.browser_added');
report('dashboard', 'browser_added');
const prevIds = new Set(Object.keys(store.getState().dashboardLayout.browserCards));
dispatch(addBrowserCard({ url: browserHomepage, expandedSessionIds }));
setTimeout(() => {
@@ -1336,7 +1336,7 @@ const DashboardInner: React.FC<DashboardProps> = ({ dashboardId, isActive = true
}, [dispatch, browserHomepage, expandedSessionIds, canvas.actions, handleHighlightCard]);
const handleAddNote = useCallback(() => {
trackEvent('dashboard.note_added');
report('dashboard', 'note_added');
const prevIds = new Set(Object.keys(store.getState().dashboardLayout.notes));
dispatch(addNote({ expandedSessionIds }));
setTimeout(() => {
@@ -1375,7 +1375,7 @@ const DashboardInner: React.FC<DashboardProps> = ({ dashboardId, isActive = true
// Context-aware fit: if a card is selected, zoom to it; otherwise fit all
const handleFitToView = useCallback(() => {
trackEvent('dashboard.fit_to_view', { has_selection: selection.selectedIds.size > 0 });
report('dashboard', 'fit_to_view', { has_selection: selection.selectedIds.size > 0 });
if (selection.selectedIds.size === 1) {
const [[id, type]] = selection.selectedIds;
const rect = getCardRect(id, type);
@@ -1388,7 +1388,7 @@ const DashboardInner: React.FC<DashboardProps> = ({ dashboardId, isActive = true
}, [selection.selectedIds, getCardRect, canvas.actions]);
const handleTidy = useCallback(() => {
trackEvent('dashboard.tidy_layout');
report('dashboard', 'tidy_layout');
const currentExpanded = store.getState().agents.expandedSessionIds;
dispatch(tidyLayout({ expandedSessionIds: currentExpanded }));
+3 -3
View File
@@ -1,5 +1,5 @@
import React, { useState, useEffect, useMemo, useCallback, useRef } from 'react';
import { trackEvent } from '@/shared/analytics';
import { report } from '@/shared/serviceClient';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import TextField from '@mui/material/TextField';
@@ -219,7 +219,7 @@ const OpenSwarmProCard: React.FC = () => {
}, [refresh]);
const handleManage = async () => {
trackEvent('subscription.manage_clicked', {
report('subscription', 'manage_clicked', {
plan: status?.plan ?? null,
status: status?.status ?? null,
});
@@ -257,7 +257,7 @@ const OpenSwarmProCard: React.FC = () => {
for (const threshold of [80, 90] as const) {
if (current >= threshold && !firedUsageThresholds.current.has(threshold)) {
firedUsageThresholds.current.add(threshold);
trackEvent('subscription.usage_warning', {
report('subscription', 'usage_warning', {
plan: status.plan ?? null,
utilization: current,
threshold,
-13
View File
@@ -1,13 +0,0 @@
// Legacy shim. Forwards to serviceClient so every existing trackEvent()
// call site routes through the cloud relay without churning ~50 call
// sites across the frontend. Deleted entirely when those call sites
// migrate (or sooner — both paths run cleanly).
//
// New code should import from '@/shared/serviceClient' directly.
export {
trackEvent,
getLastAction,
getLastPage,
getTimeSpent,
} from './serviceClient';
+7 -7
View File
@@ -4,7 +4,7 @@ import { activateSubscription } from '@/shared/state/settingsSlice';
import { fetchModels } from '@/shared/state/modelsSlice';
import { fetchTools } from '@/shared/state/toolsSlice';
import { API_BASE } from '@/shared/config';
import { trackEvent } from '@/shared/analytics';
import { report } from '@/shared/serviceClient';
// Listens for openswarm://auth?token=...&plan=...&expires=... URLs coming
// from the Electron main process via window.openswarm.onAuthUrl. Parses the
@@ -37,7 +37,7 @@ export function useDeepLink(): void {
const plan = url.searchParams.get('plan');
const expires = url.searchParams.get('expires');
trackEvent('subscription.deep_link_received', {
report('subscription', 'deep_link_received', {
plan: plan ?? 'unknown',
});
@@ -50,14 +50,14 @@ export function useDeepLink(): void {
)
.unwrap()
.then((res) => {
trackEvent('subscription.activated', { plan: res.plan });
report('subscription', 'activated', { plan: res.plan });
// Re-fetch the model list so the Claude models (via OpenSwarm
// Pro proxy) show up in the chat picker right away.
dispatch(fetchModels());
})
.catch((err) => {
console.error('[deep-link] Activation failed:', err);
trackEvent('subscription.activation_failed', {
report('subscription', 'activation_failed', {
message: String(err).slice(0, 120),
});
});
@@ -86,7 +86,7 @@ export function useDeepLink(): void {
return;
}
trackEvent('oauth.deep_link_received', { provider: url.pathname.split('/')[1] || 'unknown' });
report('oauth', 'deep_link_received', { provider: url.pathname.split('/')[1] || 'unknown' });
const resp = await fetch(`${API_BASE}/tools/oauth/claim`, {
method: 'POST',
@@ -96,10 +96,10 @@ export function useDeepLink(): void {
if (!resp.ok) {
const text = await resp.text();
console.error('[deep-link] OAuth claim failed:', resp.status, text);
trackEvent('oauth.claim_failed', { status: resp.status });
report('oauth', 'claim_failed', { status: resp.status });
return;
}
trackEvent('oauth.claim_succeeded');
report('oauth', 'claim_succeeded');
// Refresh tools so the UI reflects the newly-connected tool.
dispatch(fetchTools());
} catch (e) {
+21 -23
View File
@@ -41,26 +41,33 @@ export function sync(data: Record<string, unknown> = {}, opts: { immediate?: boo
}
}
/** Backwards-compat shim — maps legacy trackEvent calls to sync(). */
export function trackEvent(
eventType: string,
properties?: Record<string, unknown>,
useBeacon = false,
/**
* Compact ship-an-event helper. Produces the same wire shape as `sync()`
* — `{ s: surface, a: action, p: props }` — but reads as a "report a UI
* surface event" verb in caller code rather than a free-form state dump.
*
* The cloud reads (surface, action) tuples from the opaque payload and
* decides what they mean. The desktop never names what it's reporting.
*/
export function report(
surface: string,
action: string,
props?: Record<string, unknown>,
opts: { immediate?: boolean } = {},
): void {
const dot = eventType.indexOf('.');
const surface = dot > 0 ? eventType.slice(0, dot) : eventType;
const action = dot > 0 ? eventType.slice(dot + 1) : 'fired';
sync(
{ s: surface, a: action, p: properties || {} },
{ immediate: useBeacon },
);
sync({ s: surface, a: action, p: props || {} }, opts);
}
export function getSessionTraceState(): {
appStartTs: number;
lastTs: number;
currentPage: string;
} {
return { appStartTs: _appStart, lastTs: _lastTs };
return {
appStartTs: _appStart,
lastTs: _lastTs,
currentPage: typeof window === 'undefined' ? '' : (window.location.hash || window.location.pathname),
};
}
export function _resetForTest(): void {
@@ -73,14 +80,5 @@ export function _resetForTest(): void {
_lastTs = _appStart;
}
export function getLastAction(): string { return ''; }
export function getLastPage(): string {
if (typeof window === 'undefined') return '';
return window.location.hash || window.location.pathname;
}
export function getTimeSpent(): number {
return Math.round((Date.now() - _appStart) / 1000);
}
const serviceClient = { sync, trackEvent, getSessionTraceState };
const serviceClient = { sync, report, getSessionTraceState };
export default serviceClient;
@@ -1,82 +0,0 @@
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
import { API_BASE } from '@/shared/config';
const ANALYTICS_API = `${API_BASE}/service`;
export interface UsageSummary {
total_sessions: number;
total_cost_usd: number;
total_messages: number;
total_tool_calls: number;
avg_duration_seconds: number;
avg_cost_per_session: number;
completion_rate: number;
models_used: Record<string, number>;
providers_used: Record<string, number>;
top_tools: Record<string, number>;
status_breakdown: Record<string, number>;
// 9Router enrichment
total_prompt_tokens: number;
total_completion_tokens: number;
cost_by_model: Record<string, { cost: number; requests: number; prompt_tokens: number; completion_tokens: number }>;
cost_by_provider: Record<string, { cost: number; requests: number }>;
cost_source: ' 9router' | 'sdk' | 'none';
nine_router_available: boolean;
total_requests: number;
}
export interface CostBreakdown {
available: boolean;
period: string;
total_cost: number;
total_requests: number;
total_prompt_tokens: number;
total_completion_tokens: number;
by_model: Record<string, any>;
by_provider: Record<string, any>;
}
interface AnalyticsState {
summary: UsageSummary | null;
costBreakdown: CostBreakdown | null;
loading: boolean;
}
const initialState: AnalyticsState = {
summary: null,
costBreakdown: null,
loading: false,
};
export const fetchAnalyticsSummary = createAsyncThunk('analytics/fetchSummary', async () => {
const res = await fetch(`${ANALYTICS_API}/usage-summary`);
return (await res.json()) as UsageSummary;
});
export const fetchCostBreakdown = createAsyncThunk(
'analytics/fetchCostBreakdown',
async (period: string = '7d') => {
const res = await fetch(`${ANALYTICS_API}/cost-breakdown?period=${period}`);
return (await res.json()) as CostBreakdown;
},
);
const analyticsSlice = createSlice({
name: 'analytics',
initialState,
reducers: {},
extraReducers: (builder) => {
builder
.addCase(fetchAnalyticsSummary.pending, (state) => { state.loading = true; })
.addCase(fetchAnalyticsSummary.fulfilled, (state, action) => {
state.loading = false;
state.summary = action.payload;
})
.addCase(fetchAnalyticsSummary.rejected, (state) => { state.loading = false; })
.addCase(fetchCostBreakdown.fulfilled, (state, action) => {
state.costBreakdown = action.payload;
});
},
});
export default analyticsSlice.reducer;
-2
View File
@@ -11,7 +11,6 @@ import outputsReducer from './outputsSlice';
import dashboardLayoutReducer from './dashboardLayoutSlice';
import dashboardsReducer from './dashboardsSlice';
import updateReducer from './updateSlice';
import analyticsReducer from './analyticsSlice';
import modelsReducer from './modelsSlice';
import interactionReducer from './interactionSlice';
@@ -29,7 +28,6 @@ export const store = configureStore({
dashboardLayout: dashboardLayoutReducer,
dashboards: dashboardsReducer,
update: updateReducer,
analytics: analyticsReducer,
models: modelsReducer,
interaction: interactionReducer,
},
+5 -5
View File
@@ -1,4 +1,4 @@
import { trackEvent } from '@/shared/analytics';
import { report } from '@/shared/serviceClient';
export type OpenSwarmPlan = 'pro' | 'pro_plus' | 'ultra';
export type BillingInterval = 'monthly' | 'annual';
@@ -11,14 +11,14 @@ interface SubscribeOptions {
// Kicks off a Stripe Checkout session for the given plan + interval and opens
// the returned URL in the user's default browser (or a new tab fallback).
// All subscribe CTAs across Settings, Onboarding, and the 429 error card go
// through this helper so analytics shape and error handling stay consistent.
// through this helper so the wire shape and error handling stay consistent.
export async function subscribeToPlan(
plan: OpenSwarmPlan,
billingInterval: BillingInterval,
source: CheckoutSource,
opts: SubscribeOptions = {},
): Promise<void> {
trackEvent('subscription.subscribe_clicked', {
report('subscription', 'subscribe_clicked', {
source,
plan,
billing_interval: billingInterval,
@@ -26,7 +26,7 @@ export async function subscribeToPlan(
});
try {
// Cloud schema uses "yearly"; the desktop UI/analytics uses "annual".
// Cloud schema uses "yearly"; the desktop UI uses "annual".
// Normalize at the boundary so the rest of the client stays consistent.
const wireInterval = billingInterval === 'annual' ? 'yearly' : billingInterval;
const r = await fetch('https://api.openswarm.com/api/stripe/checkout', {
@@ -41,7 +41,7 @@ export async function subscribeToPlan(
const { url } = await r.json();
if (!url) return;
trackEvent('subscription.checkout_opened', {
report('subscription', 'checkout_opened', {
source,
plan,
billing_interval: billingInterval,