From d62e6a837ae88334625d99923ec1e63eaa3bd97d Mon Sep 17 00:00:00 2001 From: haikdc Date: Sat, 20 Jun 2026 09:39:04 -0700 Subject: [PATCH] [haik]: add swarm-analytics SDK integration with startup log write --- ANALYTICS_INTEGRATION_SIMPLE.md | 247 +++++++++++++++++++++++++++++ ANALYTICS_OVERVIEW.md | 255 ++++++++++++++++++++++++++++++ backend/apps/service/analytics.py | 76 +++++++++ backend/apps/service/service.py | 14 ++ backend/apps/settings/models.py | 4 + backend/apps/settings/settings.py | 3 +- backend/requirements.txt | 3 + 7 files changed, 601 insertions(+), 1 deletion(-) create mode 100644 ANALYTICS_INTEGRATION_SIMPLE.md create mode 100644 ANALYTICS_OVERVIEW.md create mode 100644 backend/apps/service/analytics.py diff --git a/ANALYTICS_INTEGRATION_SIMPLE.md b/ANALYTICS_INTEGRATION_SIMPLE.md new file mode 100644 index 00000000..785c42b9 --- /dev/null +++ b/ANALYTICS_INTEGRATION_SIMPLE.md @@ -0,0 +1,247 @@ +# Analytics Integration — Simple First Pass (one log write) + +**Goal of this pass:** wire the `swarm-analytics` SDK into the OpenSwarm desktop +backend with the *minimum* needed to prove the pipe works end to end — set up a +client once at startup and send a **single log write** (`backend_started`). No +product events, no spool yet. Those come later. + +Read `ANALYTICS_OVERVIEW.md` first for how the SDK works. This file is the +concrete edit list for the **desktop app repo** (`openswarm/`). + +--- + +## Where things live (desktop app) + +- Backend entry: `backend/main.py` — composes SubApps; **already creates + `settings.installation_id` at boot** (before the port binds). This is the + `install_id` we reuse; it is guaranteed to exist by the time any SubApp + lifespan runs. +- Service SubApp: `backend/apps/service/service.py` — its `service_lifespan()` is + where existing startup telemetry fires and is the home for our setup + first + log write. +- Settings model + store: `backend/apps/settings/models.py` (the `AppSettings` + pydantic model) and `backend/apps/settings/store.py` (`load_settings()` / + `save_settings()`). +- Settings write-guard: `backend/apps/settings/settings.py` + (`P_SERVER_OWNED_FIELDS`). + +--- + +## Decisions already made + +- **Base URL:** read from env var `OPENSWARM_ANALYTICS_URL`, defaulting to + `http://127.0.0.1:6792` for local end-to-end testing — that's the port the + analytics service listens on (its `.env` sets `BACKEND_PORT=6792`; `8324` is + only the hardcoded fallback in its `main.py` when `BACKEND_PORT` is unset). + - No port collision with the desktop backend: the analytics service runs on + `6792` and the desktop backend defaults to `8324` (`OPENSWARM_PORT`). They + can run side by side locally. +- **Token bootstrap timing:** call `register()` at startup inside + `service_lifespan()` (one blocking network round-trip at boot). The setup + helper swallows failures (returns `None`), so an offline first launch just + skips analytics and retries on the next boot. + +--- + +## The 5 edits + +### 1. Add the dependency — `backend/requirements.txt` + +Add a pinned line alongside the other pins: + +``` +swarm-analytics==0.1.0 +``` + +(Use whatever version is published. Pinned because desktop builds are +reproducible.) + +--- + +### 2. Add a token field — `backend/apps/settings/models.py` + +In the `AppSettings` model, next to the other `*_token` fields (e.g. near +`openswarm_bearer_token` / `installation_id`), add: + +```python + analytics_token: Optional[str] = None +``` + +This persists the token minted by `register()` so we only bootstrap once. + +--- + +### 3. Protect the token from renderer overwrites — `backend/apps/settings/settings.py` + +Add `"analytics_token"` to the `P_SERVER_OWNED_FIELDS` tuple, so a full-object +`PUT /api/settings` from a stale frontend snapshot can't forge or wipe it: + +```python +P_SERVER_OWNED_FIELDS = ( + "connection_mode", + "openswarm_bearer_token", + # ... existing entries ... + "installation_id", + "analytics_token", # <-- add + # ... +) +``` + +--- + +### 4. New singleton module — `backend/apps/service/analytics.py` + +Create this file. It owns the one client per process, bootstraps the token +lazily, maps the existing opt-out toggle onto `mode`, and exposes a getter + +shutdown. + +```python +"""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. +""" + +from __future__ import annotations + +import logging +import os +from typing import Optional + +from swarm_analytics import AnalyticsClient + +logger = logging.getLogger(__name__) + +P_CLIENT: Optional[AnalyticsClient] = None + + +def p_base_url() -> str: + # Local default is the analytics service's dev port (its .env BACKEND_PORT). + # In prod, set OPENSWARM_ANALYTICS_URL to the deployed analytics URL. + return os.environ.get("OPENSWARM_ANALYTICS_URL", "http://127.0.0.1:6792").rstrip("/") + + +def p_mode() -> str: + """Map the existing diagnostics/opt-out toggle onto the SDK mode. + + logs.write is the 'diagnostic' category, so it flows even in 'minimal' — + matching the legacy 'diagnostics always flow' behavior. + """ + try: + from backend.apps.settings.store import load_settings + s = load_settings() + if getattr(s, "service_diagnostics_mode", None) == "minimal": + return "minimal" + if getattr(s, "service_diagnostics_mode", None) is None and 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 guarantees this at boot; 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 +``` + +--- + +### 5. Wire it into startup + shutdown — `backend/apps/service/service.py` + +Inside `service_lifespan()`: + +**At startup**, after `settings` is loaded (the existing startup try-block, near +where the legacy `sync({...})` calls fire), add the setup + single log write: + +```python + from backend.apps.service.analytics import get_analytics_client + + client = get_analytics_client() + if client is not None: + client.logs.write( + tag="app", + subtag="backend_started", + data={"app_version": APP_VERSION}, + ) +``` + +(`APP_VERSION` is already imported in `service.py`.) + +**At shutdown**, after the existing task cancellation block (before the final +`logger.info("Service shut down")`), add: + +```python + from backend.apps.service.analytics import shutdown_analytics + shutdown_analytics() +``` + +That's the whole first pass: one client, one log line, clean teardown. + +--- + +## Verify the API names before wiring + +The signatures below are current as of this doc, but they're generated from the +service — confirm against the installed package +(`swarm_analytics/client.py` and `swarm_analytics/_generated/endpoints.py`): + +- `AnalyticsClient(base_url=..., token=..., mode=...)` +- `AnalyticsClient.register(base_url=..., install_id=...) -> str` +- `client.logs.write(tag: str, subtag: str | None = None, data: Any = None)` +- `client.flush(timeout)` / `client.close()` + +--- + +## How to test it end to end + +1. Run the **analytics service** from the analytics repo: `bash run.sh` (its + `.env` puts the backend on `6792`). +2. Run the desktop backend; the default base URL already points at `6792`, so + `bash run.sh` (from the desktop repo) is enough. To override, set + `OPENSWARM_ANALYTICS_URL=http://127.0.0.1: bash run.sh`. +3. On first boot you should see: + - a new `analytics_token` saved into the desktop `settings.json`, + - a `POST /public/identify/create_install_token` then a `POST /public/logs` + hit the analytics service, + - one `app / backend_started` row land in the analytics service's logs store. +4. Second boot should **not** call `create_install_token` again (token is reused). + +### Acceptance criteria + +- [ ] Desktop backend boots cleanly whether or not the analytics service is up + (offline = no crash, no token saved, retried next boot). +- [ ] When the service is up: exactly one `backend_started` log row per boot. +- [ ] `register()` runs once total, not once per boot. +- [ ] No `install_id` / `user_id` / `ts` / `submission_id` is passed anywhere by + hand (the SDK has no parameter for them). diff --git a/ANALYTICS_OVERVIEW.md b/ANALYTICS_OVERVIEW.md new file mode 100644 index 00000000..9761bbad --- /dev/null +++ b/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). diff --git a/backend/apps/service/analytics.py b/backend/apps/service/analytics.py new file mode 100644 index 00000000..9c0e78c4 --- /dev/null +++ b/backend/apps/service/analytics.py @@ -0,0 +1,76 @@ +"""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 +from typing import Optional + +from swarm_analytics import AnalyticsClient + +logger = logging.getLogger(__name__) + +P_CLIENT: Optional[AnalyticsClient] = None + + +def p_base_url() -> str: + # The analytics service must NOT share the desktop backend's port (8324). + # Default points at the local analytics service; override per environment. + return os.environ.get("OPENSWARM_ANALYTICS_URL", "http://127.0.0.1:6792").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 diff --git a/backend/apps/service/service.py b/backend/apps/service/service.py index e53bd0f9..db8f48ae 100644 --- a/backend/apps/service/service.py +++ b/backend/apps/service/service.py @@ -183,6 +183,17 @@ async def service_lifespan(): id_props["subscription_expires"] = settings.openswarm_subscription_expires sync({"identity": id_props}) + + # swarm-analytics: bootstrap the client (registers + persists a token on + # first run) and prove the pipe with a single diagnostic log write. + from backend.apps.service.analytics import get_analytics_client + client = get_analytics_client() + if client is not None: + client.logs.write( + tag="app", + subtag="backend_started", + data={"app_version": APP_VERSION}, + ) except Exception as e: logger.debug(f"Service startup event failed (non-critical): {e}") @@ -219,6 +230,9 @@ async def service_lifespan(): except Exception: pass + from backend.apps.service.analytics import shutdown_analytics + shutdown_analytics() + logger.info("Service shut down") diff --git a/backend/apps/settings/models.py b/backend/apps/settings/models.py index 65eaebc3..275045c9 100644 --- a/backend/apps/settings/models.py +++ b/backend/apps/settings/models.py @@ -65,6 +65,10 @@ class AppSettings(BaseModel): dismissed_mcp_suggestions: dict[str, str] = Field(default_factory=dict) analytics_opt_in: bool = True installation_id: Optional[str] = None + # Install token minted once by swarm-analytics register(); persisted so we + # never re-bootstrap. Server-owned (see P_SERVER_OWNED_FIELDS) + treated as + # a secret so a stale renderer PUT can't forge or wipe it. + analytics_token: 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 8aa55dee..9793323e 100644 --- a/backend/apps/settings/settings.py +++ b/backend/apps/settings/settings.py @@ -122,6 +122,7 @@ P_SERVER_OWNED_FIELDS = ( "user_id", "signin_method", "installation_id", + "analytics_token", "claude_subscription_token", "openai_subscription_token", "gemini_subscription_token", @@ -154,7 +155,7 @@ async def update_settings(body: AppSettings): 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..73f4cf5a 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -20,6 +20,9 @@ trafilatura==2.0.0 # 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. tzlocal==5.3.1 +# swarm-analytics: typed client for the OpenSwarm product-analytics ingest +# service. Pinned for reproducible desktop builds (see ANALYTICS_OVERVIEW.md). +swarm-analytics==0.1.0 # Test deps (pytest, pytest-asyncio) live in requirements-dev.txt — they # never ship to production users and shaved ~3 MB / ~200 files off the # Mac DMG when removed from the prod env. \ No newline at end of file