mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-30 21:44:50 +02:00
[haik]: add swarm-analytics SDK integration with startup log write
This commit is contained in:
@@ -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:<port> 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).
|
||||
@@ -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).
|
||||
@@ -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
|
||||
@@ -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")
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user