mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-08-31 12:19:48 +02:00
[eric] unify telemetry surface, install_method, frontend trackEvent
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
# ===========================================================================
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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 }));
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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';
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user