From e5cd71ca9798cc170bfad58dc17a97612aa55f17 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Mon, 22 Jun 2026 16:12:20 -0700 Subject: [PATCH 01/12] [eric] apps: seed fills missing files only so reopen keeps agent edits --- backend/apps/outputs/outputs.py | 30 +++++++---- backend/tests/test_seed_no_clobber.py | 77 +++++++++++++++++++++++++++ 2 files changed, 96 insertions(+), 11 deletions(-) create mode 100644 backend/tests/test_seed_no_clobber.py diff --git a/backend/apps/outputs/outputs.py b/backend/apps/outputs/outputs.py index 0c3d3172..65aced57 100644 --- a/backend/apps/outputs/outputs.py +++ b/backend/apps/outputs/outputs.py @@ -346,33 +346,41 @@ async def seed_workspace(body: WorkspaceSeedRequest): "already_seeded": already_seeded, } - # Legacy flat path; unchanged. + # Legacy flat path. Seed only fills in MISSING files; it never overwrites + # what's already on disk. A reopen re-sends the inline output.files snapshot, + # which lags behind whatever the agent just wrote to the workspace; writing it + # back reverted every edited file (new files survived, edited ones snapped to + # the snapshot). Disk wins once an app exists. if body.files: for rel_path, content in body.files.items(): full_path = os.path.normpath(os.path.join(folder, rel_path)) if not full_path.startswith(os.path.normpath(folder)): continue + if os.path.exists(full_path): + continue os.makedirs(os.path.dirname(full_path), exist_ok=True) with open(full_path, "w", encoding="utf-8") as f: f.write(content) else: for rel_path, content in VIEW_TEMPLATE_FILES.items(): full_path = os.path.join(folder, rel_path) + if os.path.exists(full_path): + continue with open(full_path, "w", encoding="utf-8") as f: f.write(content) - # Seed the workspace's SKILL.md with the LIVE skill content so an - # agent that Reads SKILL.md sees the same text the Skills page shows. - # Snapshot at workspace creation; subsequent edits don't rewrite - # already-seeded workspaces (the system-prompt injection in - # agent_manager reads live, so the agent always has the latest - # rules regardless of this on-disk copy). - with open(os.path.join(folder, "SKILL.md"), "w", encoding="utf-8") as f: - f.write(load_app_builder_skill()) + # SKILL.md is a creation-time snapshot; the live rules reach the agent via + # the system-prompt injection regardless, so never rewrite an existing one. + skill_path = os.path.join(folder, "SKILL.md") + if not os.path.exists(skill_path): + with open(skill_path, "w", encoding="utf-8") as f: + f.write(load_app_builder_skill()) if body.meta: - with open(os.path.join(folder, "meta.json"), "w", encoding="utf-8") as f: - json.dump(body.meta, f, indent=2) + meta_path = os.path.join(folder, "meta.json") + if not os.path.exists(meta_path): + with open(meta_path, "w", encoding="utf-8") as f: + json.dump(body.meta, f, indent=2) return {"path": os.path.abspath(folder), "template_mode": "flat"} diff --git a/backend/tests/test_seed_no_clobber.py b/backend/tests/test_seed_no_clobber.py new file mode 100644 index 00000000..3b62946a --- /dev/null +++ b/backend/tests/test_seed_no_clobber.py @@ -0,0 +1,77 @@ +"""Seed must CREATE, never overwrite. Reopening an app re-POSTs the inline +output.files snapshot, which lags behind whatever the agent last wrote to the +workspace on disk; seeding it back used to revert every edited file while the +agent's new files survived (edits looked half-reverted on the next export). + +Path constants are module-level, so (like test_versions) we monkeypatch them +into a temp tree. seed_workspace is async; we drive it with asyncio.run from a +sync test so the suite's bare-async-skip doesn't quietly no-op these.""" +import asyncio +import os + +import pytest + +from backend.apps.outputs import outputs as outputs_mod +from backend.apps.outputs.models import WorkspaceSeedRequest + + +@pytest.fixture +def ws_root(tmp_path, monkeypatch): + root = tmp_path / "ws" + root.mkdir() + monkeypatch.setattr(outputs_mod, "WORKSPACE_DIR", str(root)) + return root + + +def _seed(**kw): + return asyncio.run(outputs_mod.seed_workspace(WorkspaceSeedRequest(**kw))) + + +def _read(folder, rel): + with open(os.path.join(folder, rel), encoding="utf-8") as f: + return f.read() + + +def test_reopen_seed_preserves_agent_edits(ws_root): + wsid = "ws-reopen" + folder = os.path.join(str(ws_root), wsid) + os.makedirs(os.path.join(folder, "frontend", "src")) + # v1 on disk, captured into the inline snapshot the editor later autosaves. + with open(os.path.join(folder, "frontend", "src", "App.tsx"), "w") as f: + f.write("

v1

") + snapshot = {"frontend/src/App.tsx": "

v1

"} + + # Agent advances the workspace to v2 on disk: edits a file, adds a new one. + with open(os.path.join(folder, "frontend", "src", "App.tsx"), "w") as f: + f.write("

v2 agent

") + with open(os.path.join(folder, "frontend", "src", "New.tsx"), "w") as f: + f.write("// new v2 file") + + # Reopen replays the stale snapshot through seed. + _seed(workspace_id=wsid, files=snapshot, meta={"name": "App"}) + + assert _read(folder, "frontend/src/App.tsx") == "

v2 agent

" # not reverted + assert os.path.exists(os.path.join(folder, "frontend", "src", "New.tsx")) # survived + + +def test_fresh_seed_materializes_saved_files(ws_root): + wsid = "ws-fresh" + folder = os.path.join(str(ws_root), wsid) + _seed(workspace_id=wsid, + files={"index.html": "saved", "style.css": "body{}"}, + meta={"name": "Flat"}) + assert _read(folder, "index.html") == "saved" + assert _read(folder, "style.css") == "body{}" + + +def test_seed_fills_only_missing_files(ws_root): + wsid = "ws-partial" + folder = os.path.join(str(ws_root), wsid) + os.makedirs(folder) + with open(os.path.join(folder, "keep.txt"), "w") as f: + f.write("on-disk wins") + # snapshot wants to change keep.txt AND add gone.txt; only the missing one lands. + _seed(workspace_id=wsid, + files={"keep.txt": "snapshot loses", "gone.txt": "recreated"}) + assert _read(folder, "keep.txt") == "on-disk wins" + assert _read(folder, "gone.txt") == "recreated" From 176656b710bf8ded0cb4bda3712ed071ee013c25 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Mon, 22 Jun 2026 16:34:33 -0700 Subject: [PATCH 02/12] [eric] backend: close SSRF v4-in-v6 bypass in ssrf_guard + regression test --- backend/apps/agents/tools/ssrf_guard.py | 5 +++ backend/tests/test_ssrf_guard.py | 49 +++++++++++++++++++++++++ 2 files changed, 54 insertions(+) create mode 100644 backend/tests/test_ssrf_guard.py diff --git a/backend/apps/agents/tools/ssrf_guard.py b/backend/apps/agents/tools/ssrf_guard.py index c641844a..475770a4 100644 --- a/backend/apps/agents/tools/ssrf_guard.py +++ b/backend/apps/agents/tools/ssrf_guard.py @@ -62,6 +62,11 @@ def _is_forbidden_ip(ip_str: str) -> bool: ip = ipaddress.ip_address(ip_str) except ValueError: return True # unparseable -> block + # v6 can carry a v4 target (v4-mapped ::ffff:, 6to4 2002::) and routes to it; judge by the embedded v4 or a private host slips past the v6 list. + if ip.version == 6: + embedded = ip.ipv4_mapped or ip.sixtofour + if embedded is not None: + ip = embedded if ip.is_loopback: return False if ip.version == 4: diff --git a/backend/tests/test_ssrf_guard.py b/backend/tests/test_ssrf_guard.py new file mode 100644 index 00000000..b8490afd --- /dev/null +++ b/backend/tests/test_ssrf_guard.py @@ -0,0 +1,49 @@ +"""Regression tests for the SSRF guard. + +Covers the plain ranges and the v4-in-v6 smuggling bypass: a private/metadata +v4 target hidden inside a v6 address (v4-mapped ::ffff:, 6to4 2002::) used to +slip past the v6-only blocklist. Surfaced while running the OPENSAGE +comprehension-gap probe against this module. +""" + +import pytest + +from apps.agents.tools.ssrf_guard import SSRFBlocked, _is_forbidden_ip, assert_safe_url + + +@pytest.mark.parametrize( + "ip_str, forbidden", + [ + # plain ranges still behave + ("10.0.0.1", True), + ("169.254.169.254", True), # cloud metadata + ("8.8.8.8", False), # public + ("127.0.0.1", False), # loopback intentionally allowed + ("::1", False), # v6 loopback allowed + ("2606:4700::1", False), # public v6 + ("fe80::1", True), # v6 link-local + ("not-an-ip", True), # unparseable -> block + # the bypass: a v4 target smuggled inside a v6 address + ("::ffff:10.0.0.1", True), # v4-mapped private + ("::ffff:169.254.169.254", True), # v4-mapped cloud metadata + ("2002:0a00:0001::1", True), # 6to4 of 10.0.0.1 + ("::ffff:127.0.0.1", False), # v4-mapped loopback stays allowed + ("::ffff:8.8.8.8", False), # v4-mapped public stays allowed + ], +) +def test_is_forbidden_ip(ip_str, forbidden): + assert _is_forbidden_ip(ip_str) is forbidden + + +@pytest.mark.asyncio +async def test_assert_safe_url_blocks_v4_mapped_metadata(): + # IP-literal host short-circuits before DNS, so this needs no network. + with pytest.raises(SSRFBlocked): + await assert_safe_url("http://[::ffff:169.254.169.254]/latest/meta-data/") + + +@pytest.mark.asyncio +async def test_assert_safe_url_allows_v4_mapped_loopback(): + # App Builder previews on loopback must keep working, even v4-mapped. + url = "http://[::ffff:127.0.0.1]:8731/" + assert await assert_safe_url(url) == url From 851cf1e742b589e60324546472a7375b09e930b6 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Mon, 22 Jun 2026 16:21:16 -0700 Subject: [PATCH 03/12] [eric] apps: edit selected App in place, no duplicate on launch --- backend/apps/agents/agent_manager.py | 15 +++++ backend/apps/agents/core/models.py | 3 + backend/apps/outputs/workspace_io.py | 14 ++++- backend/tests/test_app_edit_bind.py | 58 +++++++++++++++++++ .../src/app/pages/AgentChat/AgentChat.tsx | 3 + .../app/pages/Dashboard/DashboardToolbar.tsx | 4 +- .../hooks/lifecycle/useAgentSpawn.ts | 5 ++ frontend/src/shared/state/agentsSlice.ts | 1 + 8 files changed, 101 insertions(+), 2 deletions(-) create mode 100644 backend/tests/test_app_edit_bind.py diff --git a/backend/apps/agents/agent_manager.py b/backend/apps/agents/agent_manager.py index 43286479..fbf0e377 100644 --- a/backend/apps/agents/agent_manager.py +++ b/backend/apps/agents/agent_manager.py @@ -268,6 +268,21 @@ class AgentManager: async def launch_agent(self, config: AgentConfig) -> AgentSession: session_id = uuid4().hex + # Editing an existing App: when the user selected exactly one App card + # in App Builder mode, point the chat at that app's workspace so it + # edits in place. Without this the view-builder seed below fires (no + # target_directory) and registers a fresh empty "Untitled App" dupe. + if ( + config.mode == "view-builder" + and not config.target_directory + and config.selected_app_output_ids + and len(config.selected_app_output_ids) == 1 + ): + from backend.apps.outputs.workspace_io import app_workspace_dir + bound = app_workspace_dir(config.selected_app_output_ids[0]) + if bound: + config.target_directory = bound + mode_tools, _, mode_folder = self._resolve_mode(config.mode) tools = mode_tools diff --git a/backend/apps/agents/core/models.py b/backend/apps/agents/core/models.py index de1e0832..08f7cd20 100644 --- a/backend/apps/agents/core/models.py +++ b/backend/apps/agents/core/models.py @@ -13,6 +13,9 @@ class AgentConfig(BaseModel): max_turns: Optional[int] = None target_directory: Optional[str] = None dashboard_id: Optional[str] = None + # App cards the user picked to edit. When exactly one resolves, launch + # binds the chat's cwd to that app instead of seeding a new "Untitled App". + selected_app_output_ids: Optional[list[str]] = None class ApprovalRequest(BaseModel): id: str = Field(default_factory=lambda: uuid4().hex) diff --git a/backend/apps/outputs/workspace_io.py b/backend/apps/outputs/workspace_io.py index ccb75b28..f2052b8e 100644 --- a/backend/apps/outputs/workspace_io.py +++ b/backend/apps/outputs/workspace_io.py @@ -7,7 +7,7 @@ import os from fastapi import HTTPException from backend.apps.outputs.models import Output -from backend.config.paths import OUTPUTS_DIR as DATA_DIR +from backend.config.paths import OUTPUTS_DIR as DATA_DIR, OUTPUTS_WORKSPACE_DIR from backend.config.json_store import read_json_or_none, atomic_write_json logger = logging.getLogger(__name__) @@ -46,6 +46,18 @@ def load_output(output_id: str) -> Output | None: return Output(**data) if data is not None else None +def app_workspace_dir(output_id: str) -> str | None: + """Resolve an App (Output) id to its on-disk workspace folder, or None if + the app or its folder is gone. Shared by the prompt-context builder (which + files the agent should edit) and launch (binds the chat's cwd to the app so + editing it doesn't seed a duplicate 'Untitled App').""" + output = load_output(output_id) + if not output or not output.workspace_id: + return None + path = os.path.abspath(os.path.join(OUTPUTS_WORKSPACE_DIR, output.workspace_id)) + return path if os.path.isdir(path) else None + + # Build/install/cache directories that the polling endpoint must never # descend into. Without this skip-list the workspace endpoint reads # `node_modules/` (300 MB of MUI source, when it's a real dir and not a diff --git a/backend/tests/test_app_edit_bind.py b/backend/tests/test_app_edit_bind.py new file mode 100644 index 00000000..be6a2420 --- /dev/null +++ b/backend/tests/test_app_edit_bind.py @@ -0,0 +1,58 @@ +"""Editing an existing App must bind to its workspace, never seed a dupe. + +`app_workspace_dir` is the resolver launch_agent uses to turn a selected App +(Output) id into the cwd it should edit in place. If it returns a real path, +launch sets target_directory and the view-builder seed is skipped; if it +returns None the seed fires and a duplicate "Untitled App" is born (the bug +this locks out). Path constants are module-level, so (like test_seed_no_clobber) +we monkeypatch a temp tree. +""" +import json +import os + +import pytest + +from backend.apps.outputs import workspace_io as wio +from backend.apps.outputs.models import Output + + +@pytest.fixture +def out_root(tmp_path, monkeypatch): + data = tmp_path / "outputs" + ws = tmp_path / "outputs_workspace" + data.mkdir() + ws.mkdir() + monkeypatch.setattr(wio, "DATA_DIR", str(data)) + monkeypatch.setattr(wio, "OUTPUTS_WORKSPACE_DIR", str(ws)) + return data, ws + + +def _write_output(data_dir, **kw): + o = Output(**kw) + with open(os.path.join(str(data_dir), f"{o.id}.json"), "w") as f: + json.dump(o.model_dump(), f) + return o + + +def test_resolves_existing_app_workspace(out_root): + data, ws = out_root + os.makedirs(os.path.join(str(ws), "ws-app")) + o = _write_output(data, name="Voxelcraft", workspace_id="ws-app") + assert wio.app_workspace_dir(o.id) == os.path.abspath(os.path.join(str(ws), "ws-app")) + + +def test_missing_output_returns_none(out_root): + # Deleted/bogus selection -> no bind -> launch falls through to a normal new build. + assert wio.app_workspace_dir("doesnotexist") is None + + +def test_output_without_workspace_returns_none(out_root): + data, _ = out_root + o = _write_output(data, name="NoWorkspace", workspace_id=None) + assert wio.app_workspace_dir(o.id) is None + + +def test_output_with_vanished_folder_returns_none(out_root): + data, _ = out_root + o = _write_output(data, name="Gone", workspace_id="ws-vanished") # folder never created + assert wio.app_workspace_dir(o.id) is None diff --git a/frontend/src/app/pages/AgentChat/AgentChat.tsx b/frontend/src/app/pages/AgentChat/AgentChat.tsx index 9c1768c8..c8237d3b 100644 --- a/frontend/src/app/pages/AgentChat/AgentChat.tsx +++ b/frontend/src/app/pages/AgentChat/AgentChat.tsx @@ -407,6 +407,9 @@ const AgentChat: React.FC = ({ sessionId: sessionIdProp, onClose // session lands dashboard_id=null, drops out of the reconcile filter, and its card vanishes // the instant you send (looked like "the chat quit when I clicked an option"). if (session?.dashboard_id) config.dashboard_id = session.dashboard_id; + // Editing an existing app: bind the launch to it so the backend edits in + // place instead of seeding a duplicate empty app (App Builder mode only). + if (msg.selectedAppIds?.length) config.selected_app_output_ids = msg.selectedAppIds; dispatch( launchAndSendFirstMessage({ draftId: id, config, prompt: msg.prompt, mode, model, images: msg.images, contextPaths: msg.contextPaths, forcedTools: msg.forcedTools, attachedSkills: msg.attachedSkills, selectedBrowserIds: msg.selectedBrowserIds, selectedAppIds: msg.selectedAppIds, selectedSettingIds: msg.selectedSettingIds }) ).then((action) => { diff --git a/frontend/src/app/pages/Dashboard/DashboardToolbar.tsx b/frontend/src/app/pages/Dashboard/DashboardToolbar.tsx index bedf49b5..ecf1cfc8 100644 --- a/frontend/src/app/pages/Dashboard/DashboardToolbar.tsx +++ b/frontend/src/app/pages/Dashboard/DashboardToolbar.tsx @@ -49,6 +49,7 @@ interface Props { forcedTools?: string[], attachedSkills?: Array<{ id: string; name: string; content: string }>, selectedBrowserIds?: string[], + selectedAppIds?: string[], ) => void; onAddView: (outputId: string) => void; onHistoryResume: (sessionId: string) => void; @@ -213,8 +214,9 @@ const DashboardToolbar = React.forwardRef( forcedTools?: string[], attachedSkills?: Array<{ id: string; name: string; content: string }>, selectedBrowserIds?: string[], + selectedAppIds?: string[], ) => { - onSend(message, mode, model, images, contextPaths, forcedTools, attachedSkills, selectedBrowserIds); + onSend(message, mode, model, images, contextPaths, forcedTools, attachedSkills, selectedBrowserIds, selectedAppIds); }, [onSend, mode, model], ); diff --git a/frontend/src/app/pages/Dashboard/hooks/lifecycle/useAgentSpawn.ts b/frontend/src/app/pages/Dashboard/hooks/lifecycle/useAgentSpawn.ts index 918ccc09..1cba1dc2 100644 --- a/frontend/src/app/pages/Dashboard/hooks/lifecycle/useAgentSpawn.ts +++ b/frontend/src/app/pages/Dashboard/hooks/lifecycle/useAgentSpawn.ts @@ -127,6 +127,7 @@ export function useAgentSpawn({ forcedTools?: string[], attachedSkills?: Array<{ id: string; name: string; content: string }>, selectedBrowserIds?: string[], + selectedAppIds?: string[], ) => { setToolbarOpen(false); report('dashboard', 'agent_created', { mode, model, has_images: !!images?.length, has_context: !!contextPaths?.length, has_browser: !!selectedBrowserIds?.length }); @@ -148,6 +149,9 @@ export function useAgentSpawn({ } const config: AgentConfig = { name: 'New chat', model, mode, dashboard_id: dashboardId }; + // Editing an existing app: bind the launch to it so the backend edits in + // place instead of seeding a duplicate empty app (App Builder mode only). + if (selectedAppIds?.length) config.selected_app_output_ids = selectedAppIds; dispatch( launchAndSendFirstMessage({ @@ -161,6 +165,7 @@ export function useAgentSpawn({ forcedTools, attachedSkills, selectedBrowserIds, + selectedAppIds, expand: expandNewChats, }), ).then((action) => { diff --git a/frontend/src/shared/state/agentsSlice.ts b/frontend/src/shared/state/agentsSlice.ts index 0592abe5..25631095 100644 --- a/frontend/src/shared/state/agentsSlice.ts +++ b/frontend/src/shared/state/agentsSlice.ts @@ -125,6 +125,7 @@ export interface AgentConfig { max_turns?: number; target_directory?: string; dashboard_id?: string; + selected_app_output_ids?: string[]; } export interface HistorySession { From 08355c09a685fd519dd531ee9a04a50b8338bdf0 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Tue, 23 Jun 2026 19:22:41 -0700 Subject: [PATCH 04/12] [eric] frontend: resume button persists across reload (derive from persisted stopped status) --- frontend/src/app/pages/AgentChat/AgentChat.tsx | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/frontend/src/app/pages/AgentChat/AgentChat.tsx b/frontend/src/app/pages/AgentChat/AgentChat.tsx index c8237d3b..035a85a2 100644 --- a/frontend/src/app/pages/AgentChat/AgentChat.tsx +++ b/frontend/src/app/pages/AgentChat/AgentChat.tsx @@ -481,6 +481,18 @@ const AgentChat: React.FC = ({ sessionId: sessionIdProp, onClose } }, [session?.status, mode, modesMap, id, isDraft, dispatch, dispatchMessage]); + // A reload remounts past the live running->stopped transition that first shows + // the resume button, so re-derive it once from the persisted 'stopped' status + // (transcript-gated so a cleared chat can't resurrect it). + const resumeHydratedRef = useRef(false); + useEffect(() => { + if (resumeHydratedRef.current) return; + if (session?.status === 'stopped' && (session?.messages?.length ?? 0) > 0) { + resumeHydratedRef.current = true; + setShowResumeBubble(true); + } + }, [session?.status, session?.messages?.length]); + // Idle reconcile: if the session has been 'running' for 5s with no // WebSocket activity (no new messages, no streaming updates), do a // single GET to fetch the real status from the backend. Catches the From bae5490e40478800fa1ea70ffacbe24e21491f3d Mon Sep 17 00:00:00 2001 From: ciregenz Date: Tue, 23 Jun 2026 21:14:25 -0700 Subject: [PATCH 05/12] [eric] analytics: forward-port swarm-analytics ingest onto eric/dev (configurable base_url, lifecycle + frontend-event bridge) --- backend/apps/service/analytics.py | 332 +++++++++++++++++++++++++++ backend/apps/service/client.py | 43 ++++ backend/apps/service/service.py | 73 +++++- backend/apps/settings/models.py | 5 + backend/apps/settings/settings.py | 5 +- backend/requirements.txt | 4 + frontend/src/app/Main.tsx | 6 +- frontend/src/shared/serviceClient.ts | 27 ++- 8 files changed, 490 insertions(+), 5 deletions(-) create mode 100644 backend/apps/service/analytics.py diff --git a/backend/apps/service/analytics.py b/backend/apps/service/analytics.py new file mode 100644 index 00000000..e92c435f --- /dev/null +++ b/backend/apps/service/analytics.py @@ -0,0 +1,332 @@ +"""swarm-analytics client singleton for the desktop backend. + +One client per process. Bootstraps an install token on first use (persisted to +settings) and reuses it forever. All failures are swallowed: analytics must +never break the app. See ANALYTICS_OVERVIEW.md for the SDK contract. +""" + +from __future__ import annotations + +import logging +import os +import platform +from typing import Any, Optional + +from swarm_analytics import AnalyticsClient + +logger = logging.getLogger(__name__) + +P_CLIENT: Optional[AnalyticsClient] = None + +# Where the SDK ships events. Configurable so local dev hits a local +# product-analytics-v1 (its .env BACKEND_PORT) while prod points at the +# cloud-hosted ingest via OPENSWARM_ANALYTICS_URL. NOT the desktop backend's +# port (8324); the analytics service listens on 6792. +P_DEFAULT_ANALYTICS_URL = "http://127.0.0.1:6792" + + +def p_base_url() -> str: + return os.environ.get("OPENSWARM_ANALYTICS_URL", P_DEFAULT_ANALYTICS_URL).rstrip("/") + + +def p_mode() -> str: + """Map the existing opt-out toggle onto the SDK mode. + + logs.write is the 'diagnostic' category, so it flows even in 'minimal'; + only 'product' events are muted. analytics_opt_in is the single toggle in + AppSettings, so opted-out -> 'minimal', otherwise 'full'. + """ + try: + from backend.apps.settings.store import load_settings + s = load_settings() + if not getattr(s, "analytics_opt_in", True): + return "minimal" + except Exception: + pass + return "full" + + +def get_analytics_client() -> Optional[AnalyticsClient]: + """Lazily bootstrap + cache the client. Returns None if setup fails + (e.g. offline first run) so callers can no-op safely.""" + global P_CLIENT + if P_CLIENT is not None: + return P_CLIENT + try: + from backend.apps.settings.store import load_settings, save_settings + s = load_settings() + install_id = getattr(s, "installation_id", None) + if not install_id: + return None # main.py mints this pre-bind; bail defensively + base_url = p_base_url() + token = getattr(s, "analytics_token", None) + if not token: + token = AnalyticsClient.register(base_url=base_url, install_id=install_id) + s.analytics_token = token + save_settings(s) + P_CLIENT = AnalyticsClient(base_url=base_url, token=token, mode=p_mode()) + except Exception as e: + logger.debug("analytics setup failed (non-critical): %s", e) + return None + return P_CLIENT + + +def shutdown_analytics() -> None: + global P_CLIENT + if P_CLIENT is not None: + try: + P_CLIENT.flush(timeout=2.0) + P_CLIENT.close() + finally: + P_CLIENT = None + + +# --------------------------------------------------------------------------- +# Typed fire-and-forget wrappers. Each one resolves the singleton, no-ops when +# the client is unavailable, and swallows every error (including the SDK's +# synchronous pydantic.ValidationError) so a bad/missing analytics call can +# never break a product code path. Call these from feature code, not the raw +# client. +# --------------------------------------------------------------------------- + +def track_link_email(email: Optional[str]) -> None: + if not email: + return + c = get_analytics_client() + if c is None: + return + try: + c.identify.link_email(email=email) + except Exception as e: + logger.debug("analytics link_email failed: %s", e) + + +def track_agent_created(*, id: str, dashboard_id: Optional[str] = None) -> None: + """Name-free existence/dashboard event, fired at launch. The human-readable + title arrives later via track_agent_title once it's generated.""" + c = get_analytics_client() + if c is None: + return + try: + c.events.agent.create(id=id, dashboard_id=dashboard_id) + except Exception as e: + logger.debug("analytics agent.create failed: %s", e) + + +def track_agent_title(*, id: str, title: str) -> None: + if not title: + return + c = get_analytics_client() + if c is None: + return + try: + c.events.agent.title(id=id, title=title) + except Exception as e: + logger.debug("analytics agent.title failed: %s", e) + + +def track_agent_message( + *, + agent_id: str, + seq: int, + id: str, + role: str, + content: Any = None, + parent_id: Optional[str] = None, + branch_id: int = 0, + provider: Optional[str] = None, + model: Optional[str] = None, + thinking_level: Optional[str] = None, +) -> None: + c = get_analytics_client() + if c is None: + return + try: + from swarm_analytics import AgentMessage + c.events.agent.message( + agent_id=agent_id, + seq=seq, + message=AgentMessage( + id=id, + role=role, + content=content, + parent_id=parent_id, + branch_id=branch_id, + provider=provider, + model=model, + thinking_level=thinking_level, + ), + ) + except Exception as e: + logger.debug("analytics agent.message failed: %s", e) + + +def p_branch_version(session, message: dict) -> int: + """Edit marker for events.agent.message.branch_id. + + Only the message that *created* a forked branch -- i.e. the actual edit -- gets + a non-zero version; replies and fresh turns typed on that branch reset to 0. + The edit is always the first `user` message on a forked branch (branches are + only ever born from agent_manager.edit_message), so a message that isn't that + first user message is "new" and scores 0. Repeated edits of the SAME user + message (siblings sharing a fork_point) are ranked 1, 2, ... by created_at.""" + branch_str = message.get("branch_id") or "main" + branches = getattr(session, "branches", None) or {} + b = branches.get(branch_str) + fork_point = getattr(b, "fork_point_message_id", None) if b else None + if not fork_point: + return 0 # main / never-edited path + msg_id = message.get("id") + branch_user_msgs = [ + m for m in (getattr(session, "messages", None) or []) + if getattr(m, "branch_id", None) == branch_str and getattr(m, "role", None) == "user" + ] + if not branch_user_msgs or getattr(branch_user_msgs[0], "id", None) != msg_id: + return 0 # a reply or a later new turn on this branch -> not an edit + siblings = sorted( + (x for x in branches.values() + if getattr(x, "fork_point_message_id", None) == fork_point), + key=lambda x: x.created_at, + ) + for i, x in enumerate(siblings, start=1): + if x.id == branch_str: + return i + return 0 + + +def bridge_agent_message(session_id: str, message: dict) -> None: + """Re-emit a broadcast `agent:message` as the typed events.agent.message. + + Called from ws_manager.send_to_session, the single chokepoint every agent + message (user / assistant / tool_call / tool_result / thinking, from the main + loop and the browser agent) flows through. + + `seq` is the message's index in the session's persisted history + (session.messages). Every durable message is appended there before it's + broadcast and the list is saved to the session JSON, so the index is stable + and monotonic across close -> reopen-from-history -> even a backend restart + (an in-memory counter would reset on either and collide). Messages not in the + durable history (transient notices like auth-error toasts) have no stable + anchor, so they're skipped rather than emitted with a colliding seq. Full + content is forwarded. Best-effort: never raises into the broadcast path.""" + if not isinstance(message, dict): + return + msg_id = message.get("id") + role = message.get("role") + if not msg_id or not role: + return + try: + from backend.apps.agents.agent_manager import agent_manager + sess = agent_manager.sessions.get(session_id) + except Exception: + sess = None + if sess is None: + return + msgs = getattr(sess, "messages", None) or [] + seq = next((i for i, m in enumerate(msgs) if getattr(m, "id", None) == msg_id), None) + if seq is None: + return + track_agent_message( + agent_id=session_id, + seq=seq, + id=str(msg_id), + role=str(role), + content=message.get("content"), + parent_id=message.get("parent_id"), + branch_id=p_branch_version(sess, message), + provider=getattr(sess, "provider", None), + model=getattr(sess, "model", None), + thinking_level=getattr(sess, "thinking_level", None), + ) + + +def track_dashboard_event(*, dashboard_id: str, action: str) -> None: + """action is one of: open, close, create, delete (validated by the SDK).""" + c = get_analytics_client() + if c is None: + return + try: + c.events.dashboard.event(dashboard_id=dashboard_id, action=action) + except Exception as e: + logger.debug("analytics dashboard.event failed: %s", e) + + +def track_onboarding_step(*, step_id: str, status: str) -> None: + """status is one of: started, completed, abandoned (validated by the SDK).""" + c = get_analytics_client() + if c is None: + return + try: + c.events.onboarding.step(step_id=step_id, status=status) + except Exception as e: + logger.debug("analytics onboarding.step failed: %s", e) + + +# app_lifecycle.opened is fired at most once per backend process. The renderer +# triggers it (so it carries the browser's canonical tz/locale, the only source +# that works for packaged, dev, AND open-source runs), but a renderer can remount +# or hard-reload many times against one long-lived backend -- especially in dev -- +# so this process-scoped guard is what actually enforces one event per app launch. +P_OPENED_FIRED = False + + +def persist_client_env(*, timezone: Optional[str] = None, locale: Optional[str] = None) -> None: + """Store the renderer-reported tz/locale so the cloud envelope (stamped on + every submission via client.resolve_*) can use them on dev / open-source runs + where Electron's env injection never happens. Overwrites every launch, so a + user who changed timezone since last open reports the new one. Writes to disk + only when a value actually changed, to avoid settings churn each launch.""" + tz = (timezone or "").strip() or None + loc = (locale or "").strip() or None + if tz is None and loc is None: + return + try: + from backend.apps.settings.store import load_settings, save_settings + s = load_settings() + changed = False + if tz and getattr(s, "timezone", None) != tz: + s.timezone = tz + changed = True + if loc and getattr(s, "locale", None) != loc: + s.locale = loc + changed = True + if changed: + save_settings(s) + except Exception as e: + logger.debug("analytics persist_client_env failed: %s", e) + + +def track_app_opened(*, timezone: Optional[str] = None, locale: Optional[str] = None) -> None: + """Fire app_lifecycle.opened once per backend process. tz/locale come from the + renderer (browser Intl); os/version are filled in here. Falls back to the + shared resolver only if the caller passed nothing (defensive; the renderer + path always supplies both).""" + global P_OPENED_FIRED + if P_OPENED_FIRED: + return + c = get_analytics_client() + if c is None: + return + try: + from backend.apps.service.version import APP_VERSION + from backend.apps.service.client import resolve_timezone, resolve_locale + c.events.app_lifecycle.opened( + os=platform.system(), + os_version=platform.release(), + app_version=APP_VERSION, + timezone=timezone if timezone is not None else resolve_timezone(), + locale=locale if locale is not None else resolve_locale(), + ) + P_OPENED_FIRED = True + except Exception as e: + logger.debug("analytics app_lifecycle.opened failed: %s", e) + + +def track_app_closed() -> None: + c = get_analytics_client() + if c is None: + return + try: + c.events.app_lifecycle.closed() + except Exception as e: + logger.debug("analytics app_lifecycle.closed failed: %s", e) diff --git a/backend/apps/service/client.py b/backend/apps/service/client.py index a835d8f1..c5bed0e6 100644 --- a/backend/apps/service/client.py +++ b/backend/apps/service/client.py @@ -43,6 +43,49 @@ _PATH_BY_KIND = { } _TIMEOUT_SECONDS = 5.0 + + +def resolve_timezone() -> str: + """Best-effort IANA timezone for analytics. Prefers the renderer-reported + value persisted in settings (the only source that works on dev / OSS where + Electron's env injection never runs), then the OS, then UTC.""" + try: + from backend.apps.settings.store import load_settings + tz = getattr(load_settings(), "timezone", None) + if tz: + return tz + except Exception: + pass + try: + from tzlocal import get_localzone_name + name = get_localzone_name() + if name: + return name + except Exception: + pass + try: + return time.tzname[0] or "UTC" + except Exception: + return "UTC" + + +def resolve_locale() -> str: + """Best-effort BCP-47 locale, settings-first then OS, defaulting to en-US.""" + try: + from backend.apps.settings.store import load_settings + loc = getattr(load_settings(), "locale", None) + if loc: + return loc + except Exception: + pass + try: + import locale as _locale + code = _locale.getlocale()[0] + if code: + return code.replace("_", "-") + except Exception: + pass + return "en-US" _MAX_INFLIGHT = 16 _test_sink: Optional[Any] = None diff --git a/backend/apps/service/service.py b/backend/apps/service/service.py index 93c70851..e5484dbf 100644 --- a/backend/apps/service/service.py +++ b/backend/apps/service/service.py @@ -189,6 +189,18 @@ async def service_lifespan(): id_props["subscription_expires"] = settings.openswarm_subscription_expires svc.sync({"identity": id_props}) + + # swarm-analytics: bootstrap the client (registers + persists a token on + # first run), prove the pipe with one diagnostic log write, and link the + # user's email. All best-effort; the wrappers swallow every error. + from backend.apps.service.analytics import get_analytics_client, track_link_email + analytics_client = get_analytics_client() + if analytics_client is not None: + try: + analytics_client.logs.write(tag="app", subtag="backend_started", data={"app_version": APP_VERSION}) + except Exception: + pass + track_link_email(getattr(settings, "user_email", None)) except Exception as e: logger.debug(f"Service startup event failed (non-critical): {e}") @@ -239,6 +251,15 @@ async def service_lifespan(): except Exception: pass + # swarm-analytics: fire the app-closed event and flush+close the client so + # buffered events land before the process exits. Best-effort. + try: + from backend.apps.service.analytics import track_app_closed, shutdown_analytics + track_app_closed() + shutdown_analytics() + except Exception: + pass + logger.info("Service shut down") @@ -424,6 +445,50 @@ async def service_status(): # Frontend event endpoints # --------------------------------------------------------------------------- +def p_bridge_to_analytics(item: dict) -> None: + """Re-emit frontend `report()` events through the typed swarm-analytics SDK. + + The frontend is browser-side and can't reach the analytics service + directly, so onboarding steps and dashboard open/close arrive here as + {s, a, p} envelopes. We translate the ones we care about into product + events. Best-effort: never raises (the track_* wrappers swallow errors). + Dashboard create/delete are NOT bridged here; those fire authoritatively + from the dashboards routes, bridging them too would double-count. + """ + s = item.get("s") + a = item.get("a") + p = item.get("p") or {} + if not isinstance(p, dict): + return + if s == "onboarding_v2": + status = { + "step_started": "started", + "step_completed": "completed", + "step_aborted": "abandoned", + "step_selector_timeout": "abandoned", + "step_error": "abandoned", + }.get(a) + step_id = p.get("step_id") + if status and step_id: + from backend.apps.service.analytics import track_onboarding_step + track_onboarding_step(step_id=str(step_id), status=status) + elif s == "dashboard" and a in ("open", "close"): + dashboard_id = p.get("dashboard_id") + if dashboard_id: + from backend.apps.service.analytics import track_dashboard_event + track_dashboard_event(dashboard_id=str(dashboard_id), action=a) + elif s == "app" and a == "opened": + # The renderer reports the browser's canonical IANA timezone + BCP 47 + # locale on launch. Persist them (overwriting last launch, so a timezone + # switch is picked up) for the cloud envelope, then emit the once-per- + # process app_lifecycle.opened carrying those exact values. + tz = p.get("timezone") if isinstance(p.get("timezone"), str) else None + loc = p.get("locale") if isinstance(p.get("locale"), str) else None + from backend.apps.service.analytics import persist_client_env, track_app_opened + persist_client_env(timezone=tz, locale=loc) + track_app_opened(timezone=tz, locale=loc) + + @service.router.post("/submit") async def post_submit(body=Body(...)): """Accepts three body shapes for backward compatibility: @@ -453,6 +518,7 @@ async def post_submit(body=Body(...)): if isinstance(item, dict): if any(k in item for k in ("s", "a", "p")): svc.sync(item) + p_bridge_to_analytics(item) continue kind = item.get("kind") or "" payload = item.get("payload") or {} @@ -465,6 +531,7 @@ async def post_submit(body=Body(...)): # Shape 1: frontend `report()`; flat {s, a, p, ...} if any(k in body for k in ("s", "a", "p")): svc.sync(body) + p_bridge_to_analytics(body) return {"ok": True} # Shape 2: legacy {kind, payload} kind = body.get("kind") or "" @@ -488,11 +555,13 @@ async def post_event(body: dict): if not action: action = "fired" - svc.sync({ + envelope = { "s": str(surface)[:64], "a": str(action)[:64], "p": body.get("props") or body.get("properties") or {}, - }) + } + svc.sync(envelope) + p_bridge_to_analytics(envelope) return {"ok": True} diff --git a/backend/apps/settings/models.py b/backend/apps/settings/models.py index 39e016c3..307d6471 100644 --- a/backend/apps/settings/models.py +++ b/backend/apps/settings/models.py @@ -65,6 +65,11 @@ class AppSettings(BaseModel): dismissed_mcp_suggestions: dict[str, str] = Field(default_factory=dict) analytics_opt_in: bool = True installation_id: Optional[str] = None + # Minted once by the analytics SDK's register() and reused forever; server-owned. + analytics_token: Optional[str] = None + # Renderer-reported browser Intl values, stamped on analytics submissions; server-owned. + timezone: Optional[str] = None + locale: Optional[str] = None first_opened_at: Optional[str] = None connection_mode: str = "own_key" openswarm_bearer_token: Optional[str] = None diff --git a/backend/apps/settings/settings.py b/backend/apps/settings/settings.py index fcc0a6ce..1471d5da 100644 --- a/backend/apps/settings/settings.py +++ b/backend/apps/settings/settings.py @@ -140,6 +140,9 @@ SERVER_OWNED_FIELDS = ( "user_id", "signin_method", "installation_id", + "analytics_token", + "timezone", + "locale", "claude_subscription_token", "openai_subscription_token", "gemini_subscription_token", @@ -245,7 +248,7 @@ async def apply_settings_update(body: AppSettings, protect_fields: set[str] | No secret_keys = {"anthropic_api_key", "openai_api_key", "google_api_key", "openrouter_api_key", "claude_subscription_token", "openai_subscription_token", "gemini_subscription_token", - "openswarm_bearer_token", "free_trial_token", "installation_id"} + "openswarm_bearer_token", "free_trial_token", "installation_id", "analytics_token"} safe = {k: v for k, v in body.model_dump().items() if k not in secret_keys} _sync(safe) diff --git a/backend/requirements.txt b/backend/requirements.txt index 61b0595c..5e6ebb43 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -16,6 +16,10 @@ python-dotenv==1.1.1 Pillow==12.2.0 httpx==0.28.1 trafilatura==2.0.0 +# swarm-analytics: typed client for the product-analytics ingest service. +# Validates payloads against the server schema locally; all calls are +# fire-and-forget and swallow errors so analytics can never break the app. +swarm-analytics==0.1.1 # tzlocal: dev-mode fallback for resolving the user's IANA timezone when # Electron's OPENSWARM_TIMEZONE env var isn't set (i.e. `bash run.sh`). # Packaged builds get the env var directly so this is a safety net. diff --git a/frontend/src/app/Main.tsx b/frontend/src/app/Main.tsx index 7e4cb477..64963801 100644 --- a/frontend/src/app/Main.tsx +++ b/frontend/src/app/Main.tsx @@ -78,7 +78,7 @@ if (typeof window !== 'undefined') { if (ric) ric(prefetchAll, { timeout: 1500 }); else window.setTimeout(prefetchAll, 500); } -import { report, getSessionTraceState, getRecentActions } from '@/shared/serviceClient'; +import { report, reportAppOpened, getSessionTraceState, getRecentActions } from '@/shared/serviceClient'; import { useRouteTracker } from '@/shared/hooks/useRouteTracker'; import { useDeepLink } from '@/shared/hooks/useDeepLink'; import { useWindowFocus } from '@/shared/hooks/useWindowFocus'; @@ -223,6 +223,10 @@ const SettingsLoader: React.FC<{ children: React.ReactNode }> = ({ children }) = useEffect(() => { dispatch(fetchSettings()); dispatch(fetchModels()); + // Report the app launch with the browser's canonical tz/locale so the backend + // can emit analytics app_lifecycle.opened with values that work in packaged, + // dev, and open-source builds. Guarded once per page load; backend dedupes per process. + reportAppOpened(); // Connected subscriptions live in their own slice; without this the dashboard // (and the onboarding gate) think no model is connected until the user opens // Settings > Models, so a fresh launch shows a false "connect a model" empty diff --git a/frontend/src/shared/serviceClient.ts b/frontend/src/shared/serviceClient.ts index fee2b254..780e3830 100644 --- a/frontend/src/shared/serviceClient.ts +++ b/frontend/src/shared/serviceClient.ts @@ -87,6 +87,31 @@ export function report( sync({ s: surface, a: action, p: props || {} }, opts); } +let _openedSent = false; + +/** + * Report the app launch with the browser's canonical timezone + locale (the + * Intl API gives the same values Electron does, but works in dev and the + * open-source build too, where Electron's env injection never runs). The backend + * persists these and emits analytics `app_lifecycle.opened` from them. + * + * Guarded so a remount won't re-send within one page load; the backend also + * dedupes per process, so a hard reload can't double-count an app launch. + */ +export function reportAppOpened(): void { + if (_openedSent) return; + _openedSent = true; + let timezone = ''; + let locale = ''; + try { + timezone = Intl.DateTimeFormat().resolvedOptions().timeZone || ''; + } catch { /* leave empty; backend resolver/fallback handles it */ } + try { + locale = (typeof navigator !== 'undefined' && navigator.language) || ''; + } catch { /* leave empty */ } + report('app', 'opened', { timezone, locale }, { immediate: true }); +} + export function getSessionTraceState(): { appStartTs: number; lastTs: number; @@ -99,5 +124,5 @@ export function getSessionTraceState(): { }; } -const serviceClient = { sync, report, getSessionTraceState, getRecentActions }; +const serviceClient = { sync, report, reportAppOpened, getSessionTraceState, getRecentActions }; export default serviceClient; From 3d5aa33a203ea1afc9a41840d7a4f53e642eea75 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Tue, 23 Jun 2026 21:33:09 -0700 Subject: [PATCH 06/12] [eric] analytics: wire deeper bridges (agent message via ws_manager, agent created/title, dashboard create/delete/duplicate) --- backend/apps/agents/agent_manager.py | 11 +++++++++++ backend/apps/agents/core/ws_manager.py | 10 ++++++++++ backend/apps/dashboards/dashboards.py | 16 ++++++++++++++++ 3 files changed, 37 insertions(+) diff --git a/backend/apps/agents/agent_manager.py b/backend/apps/agents/agent_manager.py index fbf0e377..30caf1d3 100644 --- a/backend/apps/agents/agent_manager.py +++ b/backend/apps/agents/agent_manager.py @@ -381,6 +381,12 @@ class AgentManager: "session": session.model_dump(mode="json"), }) + try: + from backend.apps.service.analytics import track_agent_created + track_agent_created(id=session.id, dashboard_id=session.dashboard_id) + except Exception: + pass + return session def _build_dir_tree(self, root: str, max_depth: int = 4, prefix: str = "") -> list[str]: @@ -4207,6 +4213,11 @@ class AgentManager: "session_id": session_id, "name": title, }) + try: + from backend.apps.service.analytics import track_agent_title + track_agent_title(id=session_id, title=title) + except Exception: + pass return title async def generate_turn_label( diff --git a/backend/apps/agents/core/ws_manager.py b/backend/apps/agents/core/ws_manager.py index 0a92e2dd..7ea0e913 100644 --- a/backend/apps/agents/core/ws_manager.py +++ b/backend/apps/agents/core/ws_manager.py @@ -90,6 +90,16 @@ class ConnectionManager: if event == "agent:status" and data.get("status") in TERMINAL_STATUSES: seq_log.persist_terminal(session_id, payload_str) + # Mirror every discrete agent message into swarm-analytics. Outside the + # stamp lock (fire-and-forget; must not gate the broadcast). Replays use + # ws.send_text directly, not this path, so reconnects don't double-count. + if event == "agent:message": + try: + from backend.apps.service.analytics import bridge_agent_message + bridge_agent_message(session_id, data.get("message") or {}) + except Exception: + logger.debug("agent:message analytics bridge failed", exc_info=True) + async def replay_to( self, session_id: str, websocket: WebSocket, last_seq: int ) -> dict: diff --git a/backend/apps/dashboards/dashboards.py b/backend/apps/dashboards/dashboards.py index f5fc346d..9cf4a396 100644 --- a/backend/apps/dashboards/dashboards.py +++ b/backend/apps/dashboards/dashboards.py @@ -136,6 +136,11 @@ async def list_dashboards(): async def create_dashboard(body: DashboardCreate): dashboard = Dashboard(name=body.name) _save(dashboard) + try: + from backend.apps.service.analytics import track_dashboard_event + track_dashboard_event(dashboard_id=dashboard.id, action="create") + except Exception: + pass return dashboard.model_dump(mode="json") @@ -452,6 +457,11 @@ async def delete_dashboard(dashboard_id: str): logger.warning(f"Failed to delete active session {sid} during dashboard deletion") _delete(dashboard_id) + try: + from backend.apps.service.analytics import track_dashboard_event + track_dashboard_event(dashboard_id=dashboard_id, action="delete") + except Exception: + pass return {"ok": True} @@ -554,4 +564,10 @@ async def duplicate_dashboard(dashboard_id: str): } atomic_write_json(os.path.join(DATA_DIR, f"{new_id}.json"), new_dashboard) + try: + from backend.apps.service.analytics import track_dashboard_event + track_dashboard_event(dashboard_id=new_id, action="create") + except Exception: + pass + return new_dashboard From 0bebdff030237631be7e7587ca46920b15e29ce0 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Tue, 23 Jun 2026 22:35:15 -0700 Subject: [PATCH 07/12] [eric] analytics: add the 2 track_link_email sites I missed (sign-in + settings-save), matching haik/feat/ingest exactly --- backend/apps/auth/router.py | 6 ++++++ backend/apps/settings/settings.py | 3 +++ 2 files changed, 9 insertions(+) diff --git a/backend/apps/auth/router.py b/backend/apps/auth/router.py index dbc892e7..e8a70621 100644 --- a/backend/apps/auth/router.py +++ b/backend/apps/auth/router.py @@ -76,6 +76,12 @@ def _sync_identity_to_service(settings_obj) -> None: _identify(props) except Exception as e: logger.debug("identify sync failed: %s", e) + if email: + try: + from backend.apps.service.analytics import track_link_email + track_link_email(email) + except Exception as e: + logger.debug("analytics link_email sync failed: %s", e) # --------------------------------------------------------------------------- diff --git a/backend/apps/settings/settings.py b/backend/apps/settings/settings.py index 1471d5da..699db7e2 100644 --- a/backend/apps/settings/settings.py +++ b/backend/apps/settings/settings.py @@ -266,6 +266,9 @@ async def apply_settings_update(body: AppSettings, protect_fields: set[str] | No id_props["referral_source"] = body.user_referral_source if id_props: _identify(id_props) + if body.user_email: + from backend.apps.service.analytics import track_link_email + track_link_email(body.user_email) await save_settings_async(body) From 5078e604b4b29074d8200ca4e1d2b730de0353d8 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Tue, 23 Jun 2026 22:43:48 -0700 Subject: [PATCH 08/12] [eric] analytics: trim my authored comments to one-line WHY-only; drop _locale leading-underscore --- backend/apps/service/analytics.py | 5 +---- backend/apps/service/client.py | 8 +++----- backend/apps/service/service.py | 7 ++----- backend/requirements.txt | 4 +--- 4 files changed, 7 insertions(+), 17 deletions(-) diff --git a/backend/apps/service/analytics.py b/backend/apps/service/analytics.py index e92c435f..1e938d2c 100644 --- a/backend/apps/service/analytics.py +++ b/backend/apps/service/analytics.py @@ -18,10 +18,7 @@ logger = logging.getLogger(__name__) P_CLIENT: Optional[AnalyticsClient] = None -# Where the SDK ships events. Configurable so local dev hits a local -# product-analytics-v1 (its .env BACKEND_PORT) while prod points at the -# cloud-hosted ingest via OPENSWARM_ANALYTICS_URL. NOT the desktop backend's -# port (8324); the analytics service listens on 6792. +# Env-overridable so prod points at the cloud edge; this default is the analytics service's own port, not the desktop's 8324. P_DEFAULT_ANALYTICS_URL = "http://127.0.0.1:6792" diff --git a/backend/apps/service/client.py b/backend/apps/service/client.py index c5bed0e6..bfe3f952 100644 --- a/backend/apps/service/client.py +++ b/backend/apps/service/client.py @@ -46,9 +46,7 @@ _TIMEOUT_SECONDS = 5.0 def resolve_timezone() -> str: - """Best-effort IANA timezone for analytics. Prefers the renderer-reported - value persisted in settings (the only source that works on dev / OSS where - Electron's env injection never runs), then the OS, then UTC.""" + """Settings-first (the only source that works on dev / OSS), then OS, then UTC.""" try: from backend.apps.settings.store import load_settings tz = getattr(load_settings(), "timezone", None) @@ -79,8 +77,8 @@ def resolve_locale() -> str: except Exception: pass try: - import locale as _locale - code = _locale.getlocale()[0] + import locale + code = locale.getlocale()[0] if code: return code.replace("_", "-") except Exception: diff --git a/backend/apps/service/service.py b/backend/apps/service/service.py index e5484dbf..b249dfc3 100644 --- a/backend/apps/service/service.py +++ b/backend/apps/service/service.py @@ -190,9 +190,7 @@ async def service_lifespan(): svc.sync({"identity": id_props}) - # swarm-analytics: bootstrap the client (registers + persists a token on - # first run), prove the pipe with one diagnostic log write, and link the - # user's email. All best-effort; the wrappers swallow every error. + # First-boot log write doubles as the token-registration trigger. from backend.apps.service.analytics import get_analytics_client, track_link_email analytics_client = get_analytics_client() if analytics_client is not None: @@ -251,8 +249,7 @@ async def service_lifespan(): except Exception: pass - # swarm-analytics: fire the app-closed event and flush+close the client so - # buffered events land before the process exits. Best-effort. + # Flush before the process exits or buffered events are lost. try: from backend.apps.service.analytics import track_app_closed, shutdown_analytics track_app_closed() diff --git a/backend/requirements.txt b/backend/requirements.txt index 5e6ebb43..a9b7fea0 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -16,9 +16,7 @@ python-dotenv==1.1.1 Pillow==12.2.0 httpx==0.28.1 trafilatura==2.0.0 -# swarm-analytics: typed client for the product-analytics ingest service. -# Validates payloads against the server schema locally; all calls are -# fire-and-forget and swallow errors so analytics can never break the app. +# swarm-analytics: typed client for the product-analytics ingest; fire-and-forget so it never breaks the app. swarm-analytics==0.1.1 # tzlocal: dev-mode fallback for resolving the user's IANA timezone when # Electron's OPENSWARM_TIMEZONE env var isn't set (i.e. `bash run.sh`). From cd33c0e89bfc2242f29fba944b4b974af4bb94a2 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Tue, 23 Jun 2026 23:54:07 -0700 Subject: [PATCH 09/12] [eric] analytics: convention pass: split into 3 modules (<300 each), pydantic models replace dicts, @typechecked, one-line comments --- backend/apps/agents/core/ws_manager.py | 4 +- backend/apps/service/analytics.py | 154 ++++-------------- .../apps/service/analytics_agent_bridge.py | 84 ++++++++++ .../apps/service/analytics_frontend_bridge.py | 59 +++++++ backend/apps/service/service.py | 47 +----- 5 files changed, 182 insertions(+), 166 deletions(-) create mode 100644 backend/apps/service/analytics_agent_bridge.py create mode 100644 backend/apps/service/analytics_frontend_bridge.py diff --git a/backend/apps/agents/core/ws_manager.py b/backend/apps/agents/core/ws_manager.py index 7ea0e913..10e53d6b 100644 --- a/backend/apps/agents/core/ws_manager.py +++ b/backend/apps/agents/core/ws_manager.py @@ -95,8 +95,8 @@ class ConnectionManager: # ws.send_text directly, not this path, so reconnects don't double-count. if event == "agent:message": try: - from backend.apps.service.analytics import bridge_agent_message - bridge_agent_message(session_id, data.get("message") or {}) + from backend.apps.service.analytics_agent_bridge import bridge_agent_message, BroadcastMessage + bridge_agent_message(session_id, BroadcastMessage.model_validate(data.get("message") or {})) except Exception: logger.debug("agent:message analytics bridge failed", exc_info=True) diff --git a/backend/apps/service/analytics.py b/backend/apps/service/analytics.py index 1e938d2c..0cb49189 100644 --- a/backend/apps/service/analytics.py +++ b/backend/apps/service/analytics.py @@ -1,8 +1,9 @@ -"""swarm-analytics client singleton for the desktop backend. +"""swarm-analytics client singleton + typed event wrappers for the desktop backend. -One client per process. Bootstraps an install token on first use (persisted to -settings) and reuses it forever. All failures are swallowed: analytics must -never break the app. See ANALYTICS_OVERVIEW.md for the SDK contract. +One client per process: bootstraps an install token on first use (persisted to +settings) and reuses it forever. Every call is fire-and-forget and swallows all +errors so analytics can never break the app. The agent-message and frontend-event +bridges live in their own modules. See ANALYTICS_OVERVIEW.md for the SDK contract. """ from __future__ import annotations @@ -12,6 +13,8 @@ import os import platform from typing import Any, Optional +from typeguard import typechecked + from swarm_analytics import AnalyticsClient logger = logging.getLogger(__name__) @@ -21,31 +24,30 @@ P_CLIENT: Optional[AnalyticsClient] = None # Env-overridable so prod points at the cloud edge; this default is the analytics service's own port, not the desktop's 8324. P_DEFAULT_ANALYTICS_URL = "http://127.0.0.1:6792" +# Fired at most once per process; the renderer triggers it (the only tz/locale source that works for packaged + dev + OSS) so this guard enforces once-per-launch. +P_OPENED_FIRED = False + +@typechecked def p_base_url() -> str: return os.environ.get("OPENSWARM_ANALYTICS_URL", P_DEFAULT_ANALYTICS_URL).rstrip("/") +@typechecked def p_mode() -> str: - """Map the existing opt-out toggle onto the SDK mode. - - logs.write is the 'diagnostic' category, so it flows even in 'minimal'; - only 'product' events are muted. analytics_opt_in is the single toggle in - AppSettings, so opted-out -> 'minimal', otherwise 'full'. - """ + # logs.write is diagnostic so it flows even in 'minimal'; only product events are muted. try: from backend.apps.settings.store import load_settings - s = load_settings() - if not getattr(s, "analytics_opt_in", True): + if not getattr(load_settings(), "analytics_opt_in", True): return "minimal" except Exception: pass return "full" +@typechecked def get_analytics_client() -> Optional[AnalyticsClient]: - """Lazily bootstrap + cache the client. Returns None if setup fails - (e.g. offline first run) so callers can no-op safely.""" + # Lazy bootstrap + cache; returns None (callers no-op) when setup fails, e.g. offline first run. global P_CLIENT if P_CLIENT is not None: return P_CLIENT @@ -54,7 +56,7 @@ def get_analytics_client() -> Optional[AnalyticsClient]: s = load_settings() install_id = getattr(s, "installation_id", None) if not install_id: - return None # main.py mints this pre-bind; bail defensively + return None base_url = p_base_url() token = getattr(s, "analytics_token", None) if not token: @@ -68,6 +70,7 @@ def get_analytics_client() -> Optional[AnalyticsClient]: return P_CLIENT +@typechecked def shutdown_analytics() -> None: global P_CLIENT if P_CLIENT is not None: @@ -78,14 +81,7 @@ def shutdown_analytics() -> None: P_CLIENT = None -# --------------------------------------------------------------------------- -# Typed fire-and-forget wrappers. Each one resolves the singleton, no-ops when -# the client is unavailable, and swallows every error (including the SDK's -# synchronous pydantic.ValidationError) so a bad/missing analytics call can -# never break a product code path. Call these from feature code, not the raw -# client. -# --------------------------------------------------------------------------- - +@typechecked def track_link_email(email: Optional[str]) -> None: if not email: return @@ -98,9 +94,9 @@ def track_link_email(email: Optional[str]) -> None: logger.debug("analytics link_email failed: %s", e) +@typechecked def track_agent_created(*, id: str, dashboard_id: Optional[str] = None) -> None: - """Name-free existence/dashboard event, fired at launch. The human-readable - title arrives later via track_agent_title once it's generated.""" + # Name-free existence event at launch; the human-readable title arrives later via track_agent_title. c = get_analytics_client() if c is None: return @@ -110,6 +106,7 @@ def track_agent_created(*, id: str, dashboard_id: Optional[str] = None) -> None: logger.debug("analytics agent.create failed: %s", e) +@typechecked def track_agent_title(*, id: str, title: str) -> None: if not title: return @@ -122,6 +119,7 @@ def track_agent_title(*, id: str, title: str) -> None: logger.debug("analytics agent.title failed: %s", e) +@typechecked def track_agent_message( *, agent_id: str, @@ -158,87 +156,9 @@ def track_agent_message( logger.debug("analytics agent.message failed: %s", e) -def p_branch_version(session, message: dict) -> int: - """Edit marker for events.agent.message.branch_id. - - Only the message that *created* a forked branch -- i.e. the actual edit -- gets - a non-zero version; replies and fresh turns typed on that branch reset to 0. - The edit is always the first `user` message on a forked branch (branches are - only ever born from agent_manager.edit_message), so a message that isn't that - first user message is "new" and scores 0. Repeated edits of the SAME user - message (siblings sharing a fork_point) are ranked 1, 2, ... by created_at.""" - branch_str = message.get("branch_id") or "main" - branches = getattr(session, "branches", None) or {} - b = branches.get(branch_str) - fork_point = getattr(b, "fork_point_message_id", None) if b else None - if not fork_point: - return 0 # main / never-edited path - msg_id = message.get("id") - branch_user_msgs = [ - m for m in (getattr(session, "messages", None) or []) - if getattr(m, "branch_id", None) == branch_str and getattr(m, "role", None) == "user" - ] - if not branch_user_msgs or getattr(branch_user_msgs[0], "id", None) != msg_id: - return 0 # a reply or a later new turn on this branch -> not an edit - siblings = sorted( - (x for x in branches.values() - if getattr(x, "fork_point_message_id", None) == fork_point), - key=lambda x: x.created_at, - ) - for i, x in enumerate(siblings, start=1): - if x.id == branch_str: - return i - return 0 - - -def bridge_agent_message(session_id: str, message: dict) -> None: - """Re-emit a broadcast `agent:message` as the typed events.agent.message. - - Called from ws_manager.send_to_session, the single chokepoint every agent - message (user / assistant / tool_call / tool_result / thinking, from the main - loop and the browser agent) flows through. - - `seq` is the message's index in the session's persisted history - (session.messages). Every durable message is appended there before it's - broadcast and the list is saved to the session JSON, so the index is stable - and monotonic across close -> reopen-from-history -> even a backend restart - (an in-memory counter would reset on either and collide). Messages not in the - durable history (transient notices like auth-error toasts) have no stable - anchor, so they're skipped rather than emitted with a colliding seq. Full - content is forwarded. Best-effort: never raises into the broadcast path.""" - if not isinstance(message, dict): - return - msg_id = message.get("id") - role = message.get("role") - if not msg_id or not role: - return - try: - from backend.apps.agents.agent_manager import agent_manager - sess = agent_manager.sessions.get(session_id) - except Exception: - sess = None - if sess is None: - return - msgs = getattr(sess, "messages", None) or [] - seq = next((i for i, m in enumerate(msgs) if getattr(m, "id", None) == msg_id), None) - if seq is None: - return - track_agent_message( - agent_id=session_id, - seq=seq, - id=str(msg_id), - role=str(role), - content=message.get("content"), - parent_id=message.get("parent_id"), - branch_id=p_branch_version(sess, message), - provider=getattr(sess, "provider", None), - model=getattr(sess, "model", None), - thinking_level=getattr(sess, "thinking_level", None), - ) - - +@typechecked def track_dashboard_event(*, dashboard_id: str, action: str) -> None: - """action is one of: open, close, create, delete (validated by the SDK).""" + # action is one of: open, close, create, delete (validated by the SDK). c = get_analytics_client() if c is None: return @@ -248,8 +168,9 @@ def track_dashboard_event(*, dashboard_id: str, action: str) -> None: logger.debug("analytics dashboard.event failed: %s", e) +@typechecked def track_onboarding_step(*, step_id: str, status: str) -> None: - """status is one of: started, completed, abandoned (validated by the SDK).""" + # status is one of: started, completed, abandoned (validated by the SDK). c = get_analytics_client() if c is None: return @@ -259,20 +180,9 @@ def track_onboarding_step(*, step_id: str, status: str) -> None: logger.debug("analytics onboarding.step failed: %s", e) -# app_lifecycle.opened is fired at most once per backend process. The renderer -# triggers it (so it carries the browser's canonical tz/locale, the only source -# that works for packaged, dev, AND open-source runs), but a renderer can remount -# or hard-reload many times against one long-lived backend -- especially in dev -- -# so this process-scoped guard is what actually enforces one event per app launch. -P_OPENED_FIRED = False - - +@typechecked def persist_client_env(*, timezone: Optional[str] = None, locale: Optional[str] = None) -> None: - """Store the renderer-reported tz/locale so the cloud envelope (stamped on - every submission via client.resolve_*) can use them on dev / open-source runs - where Electron's env injection never happens. Overwrites every launch, so a - user who changed timezone since last open reports the new one. Writes to disk - only when a value actually changed, to avoid settings churn each launch.""" + # Store the renderer-reported tz/locale for the cloud envelope on dev/OSS runs; disk-write only when a value actually changed. tz = (timezone or "").strip() or None loc = (locale or "").strip() or None if tz is None and loc is None: @@ -293,11 +203,8 @@ def persist_client_env(*, timezone: Optional[str] = None, locale: Optional[str] logger.debug("analytics persist_client_env failed: %s", e) +@typechecked def track_app_opened(*, timezone: Optional[str] = None, locale: Optional[str] = None) -> None: - """Fire app_lifecycle.opened once per backend process. tz/locale come from the - renderer (browser Intl); os/version are filled in here. Falls back to the - shared resolver only if the caller passed nothing (defensive; the renderer - path always supplies both).""" global P_OPENED_FIRED if P_OPENED_FIRED: return @@ -319,6 +226,7 @@ def track_app_opened(*, timezone: Optional[str] = None, locale: Optional[str] = logger.debug("analytics app_lifecycle.opened failed: %s", e) +@typechecked def track_app_closed() -> None: c = get_analytics_client() if c is None: diff --git a/backend/apps/service/analytics_agent_bridge.py b/backend/apps/service/analytics_agent_bridge.py new file mode 100644 index 00000000..f3081cd6 --- /dev/null +++ b/backend/apps/service/analytics_agent_bridge.py @@ -0,0 +1,84 @@ +"""Bridge a broadcast `agent:message` into the typed `events.agent.message`. + +Called from ws_manager.send_to_session, the single chokepoint every agent message +flows through. Best-effort: never raises into the broadcast path. +""" + +from __future__ import annotations + +import logging +from typing import Any, Optional + +from pydantic import BaseModel, ConfigDict +from typeguard import typechecked + +from backend.apps.agents.core.models import AgentSession +from backend.apps.service.analytics import track_agent_message + +logger = logging.getLogger(__name__) + + +class BroadcastMessage(BaseModel): + # An agent:message broadcast payload, validated at the WS boundary; extra fields ignored. + model_config = ConfigDict(validate_assignment=True, extra="ignore") + id: Optional[str] = None + role: Optional[str] = None + content: Any = None + parent_id: Optional[str] = None + branch_id: Optional[str] = None + + +@typechecked +def p_branch_version(session: AgentSession, message: BroadcastMessage) -> int: + # Edit marker for branch_id: only the message that CREATED a forked branch (the actual edit) scores non-zero; replies and new turns reset to 0. + branch_str = message.branch_id or "main" + branches = getattr(session, "branches", None) or {} + b = branches.get(branch_str) + fork_point = getattr(b, "fork_point_message_id", None) if b else None + if not fork_point: + return 0 + branch_user_msgs = [ + m for m in (getattr(session, "messages", None) or []) + if getattr(m, "branch_id", None) == branch_str and getattr(m, "role", None) == "user" + ] + if not branch_user_msgs or getattr(branch_user_msgs[0], "id", None) != message.id: + return 0 + siblings = sorted( + (x for x in branches.values() + if getattr(x, "fork_point_message_id", None) == fork_point), + key=lambda x: x.created_at, + ) + for i, x in enumerate(siblings, start=1): + if x.id == branch_str: + return i + return 0 + + +@typechecked +def bridge_agent_message(session_id: str, message: BroadcastMessage) -> None: + # seq is the message's stable index in the persisted history (survives close -> reopen -> restart); transient messages with no anchor are skipped. + if not message.id or not message.role: + return + try: + from backend.apps.agents.agent_manager import agent_manager + sess = agent_manager.sessions.get(session_id) + except Exception: + sess = None + if sess is None: + return + msgs = getattr(sess, "messages", None) or [] + seq = next((i for i, m in enumerate(msgs) if getattr(m, "id", None) == message.id), None) + if seq is None: + return + track_agent_message( + agent_id=session_id, + seq=seq, + id=str(message.id), + role=str(message.role), + content=message.content, + parent_id=message.parent_id, + branch_id=p_branch_version(sess, message), + provider=getattr(sess, "provider", None), + model=getattr(sess, "model", None), + thinking_level=getattr(sess, "thinking_level", None), + ) diff --git a/backend/apps/service/analytics_frontend_bridge.py b/backend/apps/service/analytics_frontend_bridge.py new file mode 100644 index 00000000..64d446e7 --- /dev/null +++ b/backend/apps/service/analytics_frontend_bridge.py @@ -0,0 +1,59 @@ +"""Bridge frontend `report()` {s, a, p} events into typed product events. + +The frontend is browser-side and can't reach the analytics service directly, so +onboarding/dashboard/app events arrive here as envelopes. Best-effort. +""" + +from __future__ import annotations + +from typing import Optional + +from pydantic import BaseModel, ConfigDict +from typeguard import typechecked + +from backend.apps.service.analytics import ( + persist_client_env, + track_app_opened, + track_dashboard_event, + track_onboarding_step, +) + +# report() action -> SDK onboarding status; the timeout/error variants both count as abandoned. +P_ONBOARDING_STATUS = { + "step_started": "started", + "step_completed": "completed", + "step_aborted": "abandoned", + "step_selector_timeout": "abandoned", + "step_error": "abandoned", +} + + +class FrontendEventProps(BaseModel): + model_config = ConfigDict(validate_assignment=True, extra="ignore") + dashboard_id: Optional[str] = None + step_id: Optional[str] = None + timezone: Optional[str] = None + locale: Optional[str] = None + + +class FrontendEvent(BaseModel): + # A report() envelope {s, a, p}; extra fields ignored at the HTTP boundary. + model_config = ConfigDict(validate_assignment=True, extra="ignore") + s: Optional[str] = None + a: Optional[str] = None + p: FrontendEventProps = FrontendEventProps() + + +@typechecked +def bridge_frontend_event(event: FrontendEvent) -> None: + # Dashboard create/delete are NOT bridged here; those fire authoritatively from the dashboards routes, so bridging them too would double-count. + if event.s == "onboarding_v2": + status = P_ONBOARDING_STATUS.get(event.a or "") + if status and event.p.step_id: + track_onboarding_step(step_id=str(event.p.step_id), status=status) + elif event.s == "dashboard" and event.a in ("open", "close"): + if event.p.dashboard_id: + track_dashboard_event(dashboard_id=str(event.p.dashboard_id), action=str(event.a)) + elif event.s == "app" and event.a == "opened": + persist_client_env(timezone=event.p.timezone, locale=event.p.locale) + track_app_opened(timezone=event.p.timezone, locale=event.p.locale) diff --git a/backend/apps/service/service.py b/backend/apps/service/service.py index b249dfc3..236a5147 100644 --- a/backend/apps/service/service.py +++ b/backend/apps/service/service.py @@ -443,47 +443,12 @@ async def service_status(): # --------------------------------------------------------------------------- def p_bridge_to_analytics(item: dict) -> None: - """Re-emit frontend `report()` events through the typed swarm-analytics SDK. - - The frontend is browser-side and can't reach the analytics service - directly, so onboarding steps and dashboard open/close arrive here as - {s, a, p} envelopes. We translate the ones we care about into product - events. Best-effort: never raises (the track_* wrappers swallow errors). - Dashboard create/delete are NOT bridged here; those fire authoritatively - from the dashboards routes, bridging them too would double-count. - """ - s = item.get("s") - a = item.get("a") - p = item.get("p") or {} - if not isinstance(p, dict): - return - if s == "onboarding_v2": - status = { - "step_started": "started", - "step_completed": "completed", - "step_aborted": "abandoned", - "step_selector_timeout": "abandoned", - "step_error": "abandoned", - }.get(a) - step_id = p.get("step_id") - if status and step_id: - from backend.apps.service.analytics import track_onboarding_step - track_onboarding_step(step_id=str(step_id), status=status) - elif s == "dashboard" and a in ("open", "close"): - dashboard_id = p.get("dashboard_id") - if dashboard_id: - from backend.apps.service.analytics import track_dashboard_event - track_dashboard_event(dashboard_id=str(dashboard_id), action=a) - elif s == "app" and a == "opened": - # The renderer reports the browser's canonical IANA timezone + BCP 47 - # locale on launch. Persist them (overwriting last launch, so a timezone - # switch is picked up) for the cloud envelope, then emit the once-per- - # process app_lifecycle.opened carrying those exact values. - tz = p.get("timezone") if isinstance(p.get("timezone"), str) else None - loc = p.get("locale") if isinstance(p.get("locale"), str) else None - from backend.apps.service.analytics import persist_client_env, track_app_opened - persist_client_env(timezone=tz, locale=loc) - track_app_opened(timezone=tz, locale=loc) + # Boundary adapter: validate the raw report() envelope into a typed event, hand it to the analytics bridge. + from backend.apps.service.analytics_frontend_bridge import bridge_frontend_event, FrontendEvent + try: + bridge_frontend_event(FrontendEvent.model_validate(item)) + except Exception: + pass @service.router.post("/submit") From c11d0f1f1db6fdafe19710a5b16c9203b3f5cb03 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Wed, 24 Jun 2026 00:39:56 -0700 Subject: [PATCH 10/12] [eric] electron: point packaged builds' analytics at the cloud edge (OPENSWARM_ANALYTICS_URL); dev stays local --- electron/main.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/electron/main.js b/electron/main.js index 9b8405e2..c078640d 100644 --- a/electron/main.js +++ b/electron/main.js @@ -926,6 +926,8 @@ async function startBackend() { // app_version="unknown". The path-based fallback stays in place so this // change is purely additive. OPENSWARM_APP_VERSION: app.getVersion(), + // Packaged builds route analytics through the cloud edge; dev leaves it unset so the backend hits the local ingest. + ...(isPackaged ? { OPENSWARM_ANALYTICS_URL: 'https://api.openswarm.com' } : {}), // Inject the user's BCP 47 locale + IANA timezone. The Python backend // doesn't have reliable APIs for either: locale.getdefaultlocale() is // deprecated and inconsistent across OSes, and Python's local-tz string From 8168f7476f1651827fd57257c2a24660993712f1 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Wed, 24 Jun 2026 01:08:12 -0700 Subject: [PATCH 11/12] [eric] analytics: group into service/analytics/ subpackage (linter folder-fix), rewire imports, trim ws_manager comment --- backend/apps/agents/agent_manager.py | 4 ++-- backend/apps/agents/core/ws_manager.py | 6 ++---- backend/apps/auth/router.py | 2 +- backend/apps/dashboards/dashboards.py | 6 +++--- backend/apps/service/analytics/__init__.py | 0 .../agent_bridge.py} | 2 +- backend/apps/service/{analytics.py => analytics/client.py} | 0 .../frontend_bridge.py} | 2 +- backend/apps/service/service.py | 6 +++--- backend/apps/settings/settings.py | 2 +- linter/config/config.json | 1 + 11 files changed, 15 insertions(+), 16 deletions(-) create mode 100644 backend/apps/service/analytics/__init__.py rename backend/apps/service/{analytics_agent_bridge.py => analytics/agent_bridge.py} (97%) rename backend/apps/service/{analytics.py => analytics/client.py} (100%) rename backend/apps/service/{analytics_frontend_bridge.py => analytics/frontend_bridge.py} (97%) diff --git a/backend/apps/agents/agent_manager.py b/backend/apps/agents/agent_manager.py index 30caf1d3..f8983a77 100644 --- a/backend/apps/agents/agent_manager.py +++ b/backend/apps/agents/agent_manager.py @@ -382,7 +382,7 @@ class AgentManager: }) try: - from backend.apps.service.analytics import track_agent_created + from backend.apps.service.analytics.client import track_agent_created track_agent_created(id=session.id, dashboard_id=session.dashboard_id) except Exception: pass @@ -4214,7 +4214,7 @@ class AgentManager: "name": title, }) try: - from backend.apps.service.analytics import track_agent_title + from backend.apps.service.analytics.client import track_agent_title track_agent_title(id=session_id, title=title) except Exception: pass diff --git a/backend/apps/agents/core/ws_manager.py b/backend/apps/agents/core/ws_manager.py index 10e53d6b..76e2b40b 100644 --- a/backend/apps/agents/core/ws_manager.py +++ b/backend/apps/agents/core/ws_manager.py @@ -90,12 +90,10 @@ class ConnectionManager: if event == "agent:status" and data.get("status") in TERMINAL_STATUSES: seq_log.persist_terminal(session_id, payload_str) - # Mirror every discrete agent message into swarm-analytics. Outside the - # stamp lock (fire-and-forget; must not gate the broadcast). Replays use - # ws.send_text directly, not this path, so reconnects don't double-count. + # Outside the stamp lock so analytics can't gate the broadcast; replays go via ws.send_text, so reconnects don't double-count. if event == "agent:message": try: - from backend.apps.service.analytics_agent_bridge import bridge_agent_message, BroadcastMessage + from backend.apps.service.analytics.agent_bridge import bridge_agent_message, BroadcastMessage bridge_agent_message(session_id, BroadcastMessage.model_validate(data.get("message") or {})) except Exception: logger.debug("agent:message analytics bridge failed", exc_info=True) diff --git a/backend/apps/auth/router.py b/backend/apps/auth/router.py index e8a70621..02480ed2 100644 --- a/backend/apps/auth/router.py +++ b/backend/apps/auth/router.py @@ -78,7 +78,7 @@ def _sync_identity_to_service(settings_obj) -> None: logger.debug("identify sync failed: %s", e) if email: try: - from backend.apps.service.analytics import track_link_email + from backend.apps.service.analytics.client import track_link_email track_link_email(email) except Exception as e: logger.debug("analytics link_email sync failed: %s", e) diff --git a/backend/apps/dashboards/dashboards.py b/backend/apps/dashboards/dashboards.py index 9cf4a396..be8a0395 100644 --- a/backend/apps/dashboards/dashboards.py +++ b/backend/apps/dashboards/dashboards.py @@ -137,7 +137,7 @@ async def create_dashboard(body: DashboardCreate): dashboard = Dashboard(name=body.name) _save(dashboard) try: - from backend.apps.service.analytics import track_dashboard_event + from backend.apps.service.analytics.client import track_dashboard_event track_dashboard_event(dashboard_id=dashboard.id, action="create") except Exception: pass @@ -458,7 +458,7 @@ async def delete_dashboard(dashboard_id: str): _delete(dashboard_id) try: - from backend.apps.service.analytics import track_dashboard_event + from backend.apps.service.analytics.client import track_dashboard_event track_dashboard_event(dashboard_id=dashboard_id, action="delete") except Exception: pass @@ -565,7 +565,7 @@ async def duplicate_dashboard(dashboard_id: str): atomic_write_json(os.path.join(DATA_DIR, f"{new_id}.json"), new_dashboard) try: - from backend.apps.service.analytics import track_dashboard_event + from backend.apps.service.analytics.client import track_dashboard_event track_dashboard_event(dashboard_id=new_id, action="create") except Exception: pass diff --git a/backend/apps/service/analytics/__init__.py b/backend/apps/service/analytics/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/apps/service/analytics_agent_bridge.py b/backend/apps/service/analytics/agent_bridge.py similarity index 97% rename from backend/apps/service/analytics_agent_bridge.py rename to backend/apps/service/analytics/agent_bridge.py index f3081cd6..60a31d20 100644 --- a/backend/apps/service/analytics_agent_bridge.py +++ b/backend/apps/service/analytics/agent_bridge.py @@ -13,7 +13,7 @@ from pydantic import BaseModel, ConfigDict from typeguard import typechecked from backend.apps.agents.core.models import AgentSession -from backend.apps.service.analytics import track_agent_message +from backend.apps.service.analytics.client import track_agent_message logger = logging.getLogger(__name__) diff --git a/backend/apps/service/analytics.py b/backend/apps/service/analytics/client.py similarity index 100% rename from backend/apps/service/analytics.py rename to backend/apps/service/analytics/client.py diff --git a/backend/apps/service/analytics_frontend_bridge.py b/backend/apps/service/analytics/frontend_bridge.py similarity index 97% rename from backend/apps/service/analytics_frontend_bridge.py rename to backend/apps/service/analytics/frontend_bridge.py index 64d446e7..685a5f57 100644 --- a/backend/apps/service/analytics_frontend_bridge.py +++ b/backend/apps/service/analytics/frontend_bridge.py @@ -11,7 +11,7 @@ from typing import Optional from pydantic import BaseModel, ConfigDict from typeguard import typechecked -from backend.apps.service.analytics import ( +from backend.apps.service.analytics.client import ( persist_client_env, track_app_opened, track_dashboard_event, diff --git a/backend/apps/service/service.py b/backend/apps/service/service.py index 236a5147..cac6ac6a 100644 --- a/backend/apps/service/service.py +++ b/backend/apps/service/service.py @@ -191,7 +191,7 @@ async def service_lifespan(): svc.sync({"identity": id_props}) # First-boot log write doubles as the token-registration trigger. - from backend.apps.service.analytics import get_analytics_client, track_link_email + from backend.apps.service.analytics.client import get_analytics_client, track_link_email analytics_client = get_analytics_client() if analytics_client is not None: try: @@ -251,7 +251,7 @@ async def service_lifespan(): # Flush before the process exits or buffered events are lost. try: - from backend.apps.service.analytics import track_app_closed, shutdown_analytics + from backend.apps.service.analytics.client import track_app_closed, shutdown_analytics track_app_closed() shutdown_analytics() except Exception: @@ -444,7 +444,7 @@ async def service_status(): def p_bridge_to_analytics(item: dict) -> None: # Boundary adapter: validate the raw report() envelope into a typed event, hand it to the analytics bridge. - from backend.apps.service.analytics_frontend_bridge import bridge_frontend_event, FrontendEvent + from backend.apps.service.analytics.frontend_bridge import bridge_frontend_event, FrontendEvent try: bridge_frontend_event(FrontendEvent.model_validate(item)) except Exception: diff --git a/backend/apps/settings/settings.py b/backend/apps/settings/settings.py index 699db7e2..d03b5270 100644 --- a/backend/apps/settings/settings.py +++ b/backend/apps/settings/settings.py @@ -267,7 +267,7 @@ async def apply_settings_update(body: AppSettings, protect_fields: set[str] | No if id_props: _identify(id_props) if body.user_email: - from backend.apps.service.analytics import track_link_email + from backend.apps.service.analytics.client import track_link_email track_link_email(body.user_email) await save_settings_async(body) diff --git a/linter/config/config.json b/linter/config/config.json index 2f4d0f21..187a76a7 100644 --- a/linter/config/config.json +++ b/linter/config/config.json @@ -137,6 +137,7 @@ "backend/apps/agents/core", "backend/apps/outputs", "backend/apps/tools_lib", + "backend/apps/service", "backend/tests", "frontend/src/shared", "frontend/src/shared/state", From d0af428c3c984a33e3817e3c1a9afe7ad94eb740 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Wed, 24 Jun 2026 01:41:07 -0700 Subject: [PATCH 12/12] [eric] analytics: re-port ANALYTICS_OVERVIEW.md SDK contract into the subpackage so the docstring reference resolves --- .../service/analytics/ANALYTICS_OVERVIEW.md | 255 ++++++++++++++++++ 1 file changed, 255 insertions(+) create mode 100644 backend/apps/service/analytics/ANALYTICS_OVERVIEW.md diff --git a/backend/apps/service/analytics/ANALYTICS_OVERVIEW.md b/backend/apps/service/analytics/ANALYTICS_OVERVIEW.md new file mode 100644 index 00000000..9761bbad --- /dev/null +++ b/backend/apps/service/analytics/ANALYTICS_OVERVIEW.md @@ -0,0 +1,255 @@ +# Analytics Overview (`swarm-analytics` SDK) + +This document explains how the OpenSwarm product-analytics system works and how +to use the `swarm-analytics` Python SDK to send analytics from the desktop app. +It is written for an engineer/agent integrating the SDK into a separate codebase. + +--- + +## 1. The big picture + +- There is a standalone **analytics ingest service** (a FastAPI app, the + `product-analytics-v1` repo). It exposes a small set of **typed POST + endpoints** under `/public/*` — one per event category. +- The **desktop app's Python backend is the single network egress** for + analytics. The React frontend never talks to the analytics service directly; + if the UI needs to record something it hands it to the local backend, which + forwards it. (This doc only covers the backend SDK.) +- The backend talks to the service through the **`swarm-analytics` pip + package** — a typed client that is **auto-generated from the service's own + pydantic models**, so the client validates payloads against the *exact* schema + the server enforces. If a call would be rejected by the server for being the + wrong shape, it fails locally first, as a `pydantic.ValidationError`, before + any network I/O. + +### Why it's "impossible to call wrong" + +- **Identity is never passed by the caller.** No method takes `install_id` or + `user_id`. The server resolves identity from the **bearer token** on every + request. There is no way to spoof or forget it. +- **Per-request metadata is auto-filled.** `ts` (client timestamp) and + `submission_id` (idempotency UUID) never appear in any method signature — the + transport stamps them automatically. +- **Enums are `Literal`s.** Fields like `action` and `status` only accept their + allowed values; a typo raises immediately. +- **Models are vendored verbatim** from the service, so client and server can't + drift (a generator + drift check guard this). + +--- + +## 2. How a call flows (sync validate, async deliver) + +The public API is **fully synchronous and fire-and-forget**: + +1. You call e.g. `client.logs.write(tag="app", subtag="started")`. +2. On the **calling thread**, the payload is validated against the pydantic + model. Bad input raises `pydantic.ValidationError` *here, in your stack*. +3. A serialized record is handed to a **background worker thread** which does the + actual HTTP POST, with retries and exponential backoff. +4. The call returns immediately. It never blocks on the network and (after + validation) never raises for delivery problems. + +**Idempotency:** `submission_id` is minted once at enqueue time and reused on +every retry (including replays from a durable spool after a restart). The server +dedups on `(install_id, submission_id)`, so retries are no-ops, never +double-writes. + +**Retry policy (handled for you):** +- `2xx` → success. +- `429` and `5xx` → retried with backoff (up to `max_attempts`, default 8). +- other `4xx` → permanent (bad data); dropped, not retried. +- network/timeout errors → retried. + +--- + +## 3. Install + +```bash +pip install swarm-analytics +``` + +(Or `pip install ./sdk` from the analytics repo root for a local build.) + +--- + +## 4. Bootstrap: minting a token (`register`) + +A fresh install has no token. `register()` is the **one unauthenticated, +blocking** call — it mints an install token from an `install_id` you own. + +```python +from swarm_analytics import AnalyticsClient + +token = AnalyticsClient.register( + base_url="https://analytics.example.com", + install_id=install_id, # your app's stable per-install UUID +) +# Persist `token`. Reuse it on every subsequent run — never call register again +# once you have a token. +``` + +- It POSTs to `/public/identify/create_install_token`. +- Raises `AuthError` on 401, `TransportError` on other failures (and on network + errors). Wrap it if you need to survive being offline on first launch. + +--- + +## 5. Constructing the client + +```python +from swarm_analytics import AnalyticsClient + +client = AnalyticsClient( + base_url="https://analytics.example.com", + token=token, # from register(), persisted + mode="full", # or "minimal" (see opt-out below) +) +``` + +Constructor options: + +| Arg | Default | Meaning | +| -------------- | ------------------ | -------------------------------------------------------------- | +| `base_url` | (required) | Root URL of the analytics service. | +| `token` | (required) | Install token from `register()`. | +| `mode` | `"full"` | `"minimal"` mutes product telemetry (see §7). | +| `spool` | `None` | Optional durable store for crash/offline survival (see §8). | +| `max_attempts` | `8` | Retry cap per record before it's dropped. | +| `on_drop` | `None` | Callback `(record, status)` when a record is permanently dropped. | + +The client starts a daemon worker thread on construction. Build **one client per +process** and reuse it (a module-level singleton is ideal). + +--- + +## 6. The full API surface + +Every method is keyword-only and returns `None`. Identity, `ts`, and +`submission_id` are intentionally absent — they're handled for you. + +### Logs (diagnostics) + +```python +client.logs.write(tag="agent", subtag="tool", data={"name": "shell"}) +client.logs.write(tag="app", subtag="backend_started", data={"app_version": "1.2.0"}) +``` + +- `tag: str` (required), `subtag: str | None = None`, `data: Any = None` + (any JSON-serializable value — stored as opaque JSON server-side). + +### Product events + +```python +# App lifecycle +client.events.app_lifecycle.opened(os="darwin", os_version="25.3.0", + app_version="1.2.0", + timezone="America/Los_Angeles", locale="en-US") +client.events.app_lifecycle.closed() + +# Agent sessions +client.events.agent.create(id="sess_123", name="Refactor auth", dashboard_id="dash_1") +client.events.agent.message(agent_id="sess_123", seq=0, + message=AgentMessage(id="m1", role="user", content="hello")) + +# Dashboards +client.events.dashboard.event(dashboard_id="dash_1", action="create") # open|close|create|delete + +# Onboarding +client.events.onboarding.step(step_id="connect_provider", status="completed") # started|completed|abandoned +``` + +`AgentMessage` is importable from the package: + +```python +from swarm_analytics import AgentMessage +``` + +### Identity + +```python +client.identify.link_email(email="user@example.com") +``` + +Links an email to the current install (resolved from the token). Use it once the +user provides an email; do **not** pass any id. + +--- + +## 7. Categories and opt-out (`mode`) + +Every endpoint has a category. `mode="minimal"` mutes only the `product` +category; everything else still flows: + +| Category | Endpoints | Flows in `minimal`? | +| ------------ | -------------------------------------- | ------------------- | +| `product` | all `client.events.*` | **No** (muted) | +| `diagnostic` | `client.logs.write` | **Yes** | +| `identity` | `client.identify.link_email` | **Yes** | +| `bootstrap` | `register()` | **Yes** | + +So a **log write always flows**, even when the user opted out of product +telemetry. Map your app's existing opt-out toggle onto `mode`: opted-out → +`"minimal"`, otherwise `"full"`. + +--- + +## 8. Durability (optional spool) + +By default, in-flight records live in an in-memory queue and are lost if the +process dies with deliveries pending. Pass a spool to persist them to disk and +replay on next launch (with the same `submission_id`, so dedup still holds): + +```python +from swarm_analytics import SqliteSpool + +client = AnalyticsClient(base_url=..., token=..., + spool=SqliteSpool("/path/to/service_spool.db")) +``` + +For the initial integration you can skip this; add it once the basic path works. + +--- + +## 9. Shutdown + +Flush pending records before the process exits so you don't lose the tail: + +```python +client.flush(timeout=2.0) # block until drained, or give up after 2s +client.close() # stop the worker thread +``` + +`AnalyticsClient` is also a context manager (`__exit__` flushes + closes). + +--- + +## 10. Error model + +| Where | What you get | +| -------------------------- | ------------------------------------------------------------------- | +| Bad call arguments | `pydantic.ValidationError`, synchronously, on the calling thread. | +| `register()` rejected/fail | `AuthError` (401) or `TransportError` (other / network). | +| Delivery failures | Handled internally (retry/drop). Never raised to the caller. | + +Importable errors: + +```python +from swarm_analytics import AnalyticsError, AuthError, RateLimited, TransportError, ValidationRejected +``` + +--- + +## 11. Quick do / don't + +**Do** +- Create exactly one `AnalyticsClient` per process and reuse it. +- Call `register()` once, persist the token, reuse it forever. +- Let the SDK fill `ts`/`submission_id`; let the server resolve identity. +- `flush()` + `close()` on shutdown. + +**Don't** +- Don't pass `install_id`, `user_id`, `ts`, or `submission_id` — there's no + parameter for them by design. +- Don't construct a new client per event. +- Don't call `register()` on every launch. +- Don't hand-edit anything under `_generated/` (it's regenerated from the service).