swarm-analytics 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,32 @@
1
+ """swarm_analytics — typed client for the OpenSwarm product-analytics ingest API.
2
+
3
+ The event payload models under ``swarm_analytics`` are vendored verbatim from the
4
+ analytics service and re-exported here, so callers validate against the exact
5
+ schema the server enforces.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from ._generated.models import * # noqa: F401,F403
11
+ from ._generated.models import __all__ as _model_all
12
+ from .client import AnalyticsClient
13
+ from .errors import (
14
+ AnalyticsError,
15
+ AuthError,
16
+ RateLimited,
17
+ TransportError,
18
+ ValidationRejected,
19
+ )
20
+ from .spool import SqliteSpool, Spool
21
+
22
+ __all__ = [
23
+ "AnalyticsClient",
24
+ "AnalyticsError",
25
+ "AuthError",
26
+ "RateLimited",
27
+ "TransportError",
28
+ "ValidationRejected",
29
+ "SqliteSpool",
30
+ "Spool",
31
+ *_model_all,
32
+ ]
@@ -0,0 +1 @@
1
+ """Generated SDK surface (do not edit)."""
@@ -0,0 +1,71 @@
1
+ """Typed endpoint namespaces (generated — do not edit)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Literal
6
+ from ..transport import Transport
7
+ from .models.agent import AgentMessage
8
+
9
+ class AgentNS:
10
+ def __init__(self, t: Transport) -> None:
11
+ self.t = t
12
+
13
+ def create(self, *, id: str, name: str | None = None, dashboard_id: str | None = None) -> None:
14
+ self.t.send("events.agent.create", {"id": id, "name": name, "dashboard_id": dashboard_id})
15
+
16
+
17
+ def message(self, *, agent_id: str, seq: int, message: AgentMessage) -> None:
18
+ self.t.send("events.agent.message", {"agent_id": agent_id, "seq": seq, "message": message})
19
+
20
+
21
+ class AppLifecycleNS:
22
+ def __init__(self, t: Transport) -> None:
23
+ self.t = t
24
+
25
+ def closed(self) -> None:
26
+ self.t.send("events.app_lifecycle.closed", {})
27
+
28
+
29
+ def opened(self, *, os: str, os_version: str, timezone: str | None = None, locale: str | None = None, app_version: str) -> None:
30
+ self.t.send("events.app_lifecycle.opened", {"os": os, "os_version": os_version, "timezone": timezone, "locale": locale, "app_version": app_version})
31
+
32
+
33
+ class DashboardNS:
34
+ def __init__(self, t: Transport) -> None:
35
+ self.t = t
36
+
37
+ def event(self, *, dashboard_id: str, action: Literal['open', 'close', 'create', 'delete']) -> None:
38
+ self.t.send("events.dashboard.event", {"dashboard_id": dashboard_id, "action": action})
39
+
40
+
41
+ class OnboardingNS:
42
+ def __init__(self, t: Transport) -> None:
43
+ self.t = t
44
+
45
+ def step(self, *, step_id: str, status: Literal['started', 'completed', 'abandoned']) -> None:
46
+ self.t.send("events.onboarding.step", {"step_id": step_id, "status": status})
47
+
48
+
49
+ class EventsNS:
50
+ def __init__(self, t: Transport) -> None:
51
+ self.t = t
52
+ self.agent: AgentNS = AgentNS(t)
53
+ self.app_lifecycle: AppLifecycleNS = AppLifecycleNS(t)
54
+ self.dashboard: DashboardNS = DashboardNS(t)
55
+ self.onboarding: OnboardingNS = OnboardingNS(t)
56
+
57
+
58
+ class IdentifyNS:
59
+ def __init__(self, t: Transport) -> None:
60
+ self.t = t
61
+
62
+ def link_email(self, *, email: str) -> None:
63
+ self.t.send("identify.link_email", {"email": email})
64
+
65
+
66
+ class LogsNS:
67
+ def __init__(self, t: Transport) -> None:
68
+ self.t = t
69
+
70
+ def write(self, *, tag: str, subtag: str | None = None, data: Any = None) -> None:
71
+ self.t.send("logs.write", {"tag": tag, "subtag": subtag, "data": data})
@@ -0,0 +1,23 @@
1
+ """Vendored payload models (generated — do not edit)."""
2
+
3
+ from ._shared import DeviceContext, IngestMeta
4
+ from .agent import AgentCreated, AgentMessage, AgentMessageEvent
5
+ from .app_lifecycle import AppOpened
6
+ from .dashboard import DashboardEvent
7
+ from .identify import IdentifyRequest, RegisterRequest
8
+ from .logs import LogWrite
9
+ from .onboarding import OnboardingStep
10
+
11
+ __all__ = [
12
+ "AgentCreated",
13
+ "AgentMessage",
14
+ "AgentMessageEvent",
15
+ "AppOpened",
16
+ "DashboardEvent",
17
+ "DeviceContext",
18
+ "IdentifyRequest",
19
+ "IngestMeta",
20
+ "LogWrite",
21
+ "OnboardingStep",
22
+ "RegisterRequest",
23
+ ]
@@ -0,0 +1,31 @@
1
+ """Base payload models shared by every event subapp.
2
+
3
+ Identity (install_id / user_id) is NEVER part of a payload — it is resolved
4
+ server-side from the Authorization header by the auth dependency. Every payload
5
+ carries only the per-request fields (ts, submission_id) via IngestMeta.
6
+ """
7
+
8
+ from pydantic import BaseModel, Field
9
+
10
+
11
+ class IngestMeta(BaseModel):
12
+ """Minimal per-request metadata shared by every event payload.
13
+
14
+ Doubles as the payload type for events that carry no extra args.
15
+ """
16
+
17
+ ts: float = Field(..., description="Client-side unix timestamp (seconds)")
18
+ submission_id: str = Field(..., description="UUID for idempotency dedup")
19
+
20
+
21
+ class DeviceContext(BaseModel):
22
+ """The four attributes whose combination defines a device_context row.
23
+
24
+ app_version and geo are NOT here — app_version lives on the spine (carried by
25
+ app.opened), geo is derived from edge headers per event.
26
+ """
27
+
28
+ os: str
29
+ os_version: str
30
+ timezone: str | None = None
31
+ locale: str | None = None
@@ -0,0 +1,68 @@
1
+ """Typed payloads for the agent event subapp.
2
+
3
+ A agent is captured incrementally over two endpoints, mirroring the agent's
4
+ real lifecycle:
5
+
6
+ - agent.created — emitted once when the agent is created. Carries only the
7
+ scalar agent fields (id/name/dashboard_id); no transcript yet.
8
+ - agent.message — emitted once per transcript message as it happens. Carries
9
+ a single message plus a agent-scoped `seq` for ordering.
10
+
11
+ The one genuinely dynamic leaf — message `content` — is typed as `JsonValue`
12
+ (Pydantic's recursive JSON type), never `Any`. Unmodeled fields from the desktop
13
+ dump are ignored (the client may still ship a richer object); we persist only the
14
+ trimmed columns
15
+ """
16
+
17
+ from typing import Literal, Optional
18
+
19
+ from pydantic import BaseModel, Field, JsonValue, ConfigDict
20
+
21
+ from ._shared import IngestMeta
22
+
23
+
24
+ class AgentMessage(BaseModel):
25
+ """One transcript message. `content` is polymorphic (text, content blocks, or
26
+ a tool payload) so it's typed as JsonValue. `error` is a terminal role used to
27
+ infer agent outcome at query time."""
28
+
29
+ model_config = ConfigDict(validate_assignment=True) # Ensures you can't reassign new types to variables
30
+
31
+ id: str
32
+ role: Literal[
33
+ "user", "assistant", "tool_call", "tool_result", "system", "thinking", "error"
34
+ ]
35
+ content: JsonValue = None # NOTE: JsonValue is a descriminated union so it can be None
36
+ parent_id: Optional[str] = None
37
+ provider: Optional[str] = None
38
+ model: Optional[str] = None
39
+ thinking_level: Optional[Literal["off", "low", "medium", "high", "auto"]] = None
40
+
41
+ model_config = {"populate_by_name": True}
42
+
43
+
44
+ class AgentCreated(IngestMeta):
45
+ """POST /created — the scalar agent record, sent when the agent is
46
+ created. No transcript: messages arrive separately via POST /message."""
47
+
48
+ model_config = ConfigDict(validate_assignment=True) # Ensures you can't reassign new types to variables
49
+
50
+ agent_id: str = Field(..., alias="id")
51
+ name: Optional[str] = None
52
+ dashboard_id: Optional[str] = None
53
+
54
+ model_config = {"populate_by_name": True}
55
+
56
+
57
+ class AgentMessageEvent(IngestMeta):
58
+ """POST /message — one transcript message appended to an existing agent.
59
+
60
+ `seq` is a agent-scoped, client-supplied ordinal; it is the durable
61
+ ordering key (do not rely on server receive time, since messages can be
62
+ streamed/retried out of order)."""
63
+
64
+ model_config = ConfigDict(validate_assignment=True) # Ensures you can't reassign new types to variables
65
+
66
+ agent_id: str
67
+ seq: int
68
+ message: AgentMessage
@@ -0,0 +1,15 @@
1
+ """Typed payloads for the app_lifecycle event subapp.
2
+
3
+ Only open/close survive. app.opened carries the four device-context attributes
4
+ (via DeviceContext) plus app_version, which is stamped on the spine. app.closed
5
+ is a bare meta event.
6
+ """
7
+
8
+ from ._shared import DeviceContext, IngestMeta
9
+
10
+
11
+ class AppOpened(IngestMeta, DeviceContext):
12
+ """POST /opened — sent once per launch. Carries the device fields that define
13
+ the device_context row, plus app_version (stamped on the spine)."""
14
+
15
+ app_version: str
@@ -0,0 +1,16 @@
1
+ """Typed payload for the dashboard event subapp.
2
+
3
+ One consolidated route carries the lifecycle action in the body; the
4
+ event_dashboard.event_type CHECK constraint already allows these four values.
5
+ """
6
+
7
+ from typing import Literal
8
+
9
+ from ._shared import IngestMeta
10
+
11
+
12
+ class DashboardEvent(IngestMeta):
13
+ """POST "" — one dashboard lifecycle action (open/close/create/delete)."""
14
+
15
+ dashboard_id: str
16
+ action: Literal["open", "close", "create", "delete"]
@@ -0,0 +1,22 @@
1
+ """Request/response models for the identify SubApp.
2
+
3
+ Kept in a dedicated models.py (like every other domain) so the generated SDK can
4
+ vendor them verbatim. These are NOT events: they carry no IngestMeta (ts /
5
+ submission_id). Identity for authed routes is resolved from the bearer token, so
6
+ only the email travels in the link body.
7
+ """
8
+
9
+ from pydantic import BaseModel, Field
10
+
11
+
12
+ class RegisterRequest(BaseModel):
13
+ install_id: str = Field(..., min_length=1, max_length=128)
14
+
15
+
16
+ class RegisterResponse(BaseModel):
17
+ token: str
18
+ install_id: str
19
+
20
+
21
+ class IdentifyRequest(BaseModel):
22
+ email: str = Field(..., min_length=1, max_length=320)
@@ -0,0 +1,17 @@
1
+ """Payload model for the logs SubApp.
2
+
3
+ Reuses the shared IngestMeta (ts + submission_id); identity is resolved
4
+ server-side from the Authorization header, never from the body. `data` is any
5
+ JSON-serializable value — it is serialized with to_json() and stored as opaque
6
+ JSON text (DB-enforced via json_valid()).
7
+ """
8
+
9
+ from typing import Any
10
+
11
+ from ._shared import IngestMeta
12
+
13
+
14
+ class LogWrite(IngestMeta):
15
+ tag: str
16
+ subtag: str | None = None
17
+ data: Any = None
@@ -0,0 +1,16 @@
1
+ """Typed payload for the onboarding event subapp.
2
+
3
+ The whole funnel collapses to one event: a step transition carrying which step
4
+ and its funnel status.
5
+ """
6
+
7
+ from typing import Literal
8
+
9
+ from ._shared import IngestMeta
10
+
11
+
12
+ class OnboardingStep(IngestMeta):
13
+ """POST /step — one onboarding step transition."""
14
+
15
+ step_id: str
16
+ status: Literal["started", "completed", "abandoned"]
@@ -0,0 +1,22 @@
1
+ """Route table (generated — do not edit)."""
2
+
3
+ from ..transport import Route
4
+ from .models._shared import IngestMeta
5
+ from .models.agent import AgentCreated, AgentMessageEvent
6
+ from .models.app_lifecycle import AppOpened
7
+ from .models.dashboard import DashboardEvent
8
+ from .models.identify import IdentifyRequest, RegisterRequest
9
+ from .models.logs import LogWrite
10
+ from .models.onboarding import OnboardingStep
11
+
12
+ ROUTES: dict[str, Route] = {
13
+ "events.agent.create": Route("POST", "/public/events/agent/create", AgentCreated, True, "product"),
14
+ "events.agent.message": Route("POST", "/public/events/agent/message", AgentMessageEvent, True, "product"),
15
+ "events.app_lifecycle.closed": Route("POST", "/public/events/app_lifecycle/closed", IngestMeta, True, "product"),
16
+ "events.app_lifecycle.opened": Route("POST", "/public/events/app_lifecycle/opened", AppOpened, True, "product"),
17
+ "events.dashboard.event": Route("POST", "/public/events/dashboard/event", DashboardEvent, True, "product"),
18
+ "events.onboarding.step": Route("POST", "/public/events/onboarding/step", OnboardingStep, True, "product"),
19
+ "identify.link_email": Route("POST", "/public/identify/link_email_to_install", IdentifyRequest, True, "identity"),
20
+ "identify.register": Route("POST", "/public/identify/create_install_token", RegisterRequest, False, "bootstrap"),
21
+ "logs.write": Route("POST", "/public/logs", LogWrite, True, "diagnostic"),
22
+ }
@@ -0,0 +1,83 @@
1
+ """The public client.
2
+
3
+ ``AnalyticsClient`` ties the background transport to the generated, typed
4
+ endpoint namespaces. Identity is the bearer token; no method ever takes an
5
+ install_id/user_id. ``ts``/``submission_id`` are auto-filled by the transport, so
6
+ they are absent from every signature.
7
+
8
+ Bootstrap (``register``) is the one unauthenticated, *blocking* call: a fresh
9
+ install has no token, so this is how it mints one.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from typing import Optional
15
+
16
+ import httpx
17
+
18
+ from ._generated.endpoints import EventsNS, IdentifyNS, LogsNS
19
+ from ._generated.routes import ROUTES
20
+ from .errors import AuthError, TransportError
21
+ from .spool import Spool
22
+ from .transport import DropCallback, Transport
23
+
24
+
25
+ class AnalyticsClient:
26
+ def __init__(
27
+ self,
28
+ *,
29
+ base_url: str,
30
+ token: str,
31
+ mode: str = "full",
32
+ spool: Optional[Spool] = None,
33
+ http_client: Optional[httpx.Client] = None,
34
+ max_attempts: int = 8,
35
+ on_drop: Optional[DropCallback] = None,
36
+ ) -> None:
37
+ self.transport = Transport(
38
+ base_url=base_url,
39
+ token=token,
40
+ routes=ROUTES,
41
+ mode=mode,
42
+ spool=spool,
43
+ http_client=http_client,
44
+ max_attempts=max_attempts,
45
+ on_drop=on_drop,
46
+ )
47
+ self.events: EventsNS = EventsNS(self.transport)
48
+ self.logs: LogsNS = LogsNS(self.transport)
49
+ self.identify: IdentifyNS = IdentifyNS(self.transport)
50
+
51
+ @classmethod
52
+ def register(cls, *, base_url: str, install_id: str, timeout: float = 10.0) -> str:
53
+ """Mint and return an install token (unauthenticated, blocking).
54
+
55
+ Call once on first launch, persist the returned token, and pass it to the
56
+ constructor on every subsequent run.
57
+ """
58
+ route = ROUTES["identify.register"]
59
+ url = base_url.rstrip("/") + route.path
60
+ body = route.model(install_id=install_id).model_dump(by_alias=True, mode="json")
61
+ with httpx.Client(timeout=timeout) as client:
62
+ resp = client.post(url, json=body)
63
+ if resp.status_code == 401:
64
+ raise AuthError("register rejected")
65
+ if resp.status_code >= 400:
66
+ raise TransportError(f"register failed: {resp.status_code} {resp.text}")
67
+ return resp.json()["token"]
68
+
69
+ def flush(self, timeout: Optional[float] = None) -> bool:
70
+ """Block until pending events are delivered (or timeout). Call before exit."""
71
+ return self.transport.flush(timeout)
72
+
73
+ def close(self) -> None:
74
+ self.transport.close()
75
+
76
+ def __enter__(self) -> "AnalyticsClient":
77
+ return self
78
+
79
+ def __exit__(self, *exc: object) -> None:
80
+ try:
81
+ self.flush(timeout=2.0)
82
+ finally:
83
+ self.close()
@@ -0,0 +1,32 @@
1
+ """Typed exception hierarchy for the SDK.
2
+
3
+ Mirrors the server's retryable/non-retryable split so callers (and the transport
4
+ worker) can branch on category rather than raw status codes.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+
10
+ class AnalyticsError(Exception):
11
+ """Base class for every error raised by the SDK."""
12
+
13
+
14
+ class AuthError(AnalyticsError):
15
+ """401 — the bearer token is missing, malformed, or expired."""
16
+
17
+
18
+ class RateLimited(AnalyticsError):
19
+ """429 — retryable; the worker backs off and retries."""
20
+
21
+
22
+ class TransportError(AnalyticsError):
23
+ """5xx / timeout / network failure — retryable."""
24
+
25
+
26
+ class ValidationRejected(AnalyticsError):
27
+ """422 — the server rejected the payload as malformed.
28
+
29
+ Should never happen in practice: the SDK validates against the same pydantic
30
+ models the server enforces, so a bad call raises pydantic's ``ValidationError``
31
+ locally, before anything is sent. Surfaced for completeness only.
32
+ """
File without changes
@@ -0,0 +1,92 @@
1
+ """Optional durable outbox.
2
+
3
+ A spool persists not-yet-delivered events to disk so they survive a crash or an
4
+ offline-then-quit. It is OFF by default: the transport uses an in-memory queue
5
+ unless a spool is supplied, in which case undelivered events are lost on exit.
6
+
7
+ The contract is intentionally tiny (add / remove / load). The default
8
+ ``SqliteSpool`` stores one row per record keyed by ``spool_key`` (the event's
9
+ ``submission_id`` when it has one), so a replay after restart reuses the same id
10
+ and the server dedups any event that actually went out before the crash.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import sqlite3
17
+ import threading
18
+ from typing import Protocol, runtime_checkable
19
+
20
+
21
+ @runtime_checkable
22
+ class Spool(Protocol):
23
+ """Durable store for pending records. Implementations must be thread-safe."""
24
+
25
+ def add(self, spool_key: str, record: dict) -> None:
26
+ """Insert or replace the pending record under ``spool_key``."""
27
+
28
+ def remove(self, spool_key: str) -> None:
29
+ """Delete the record once it is delivered or permanently dropped."""
30
+
31
+ def load(self) -> list[tuple[str, dict]]:
32
+ """Return all pending ``(spool_key, record)`` pairs (called at startup)."""
33
+
34
+
35
+ class SqliteSpool:
36
+ """A bounded, file-backed spool. Past ``max_bytes`` the oldest rows are pruned."""
37
+
38
+ def __init__(self, path: str, max_bytes: int = 50_000_000) -> None:
39
+ self.path = path
40
+ self.max_bytes = max_bytes
41
+ self.lock = threading.Lock()
42
+ self.conn = sqlite3.connect(path, check_same_thread=False)
43
+ self.conn.execute(
44
+ "CREATE TABLE IF NOT EXISTS spool ("
45
+ " spool_key TEXT PRIMARY KEY,"
46
+ " record TEXT NOT NULL,"
47
+ " created_at REAL NOT NULL DEFAULT (julianday('now')))"
48
+ )
49
+ self.conn.commit()
50
+
51
+ def add(self, spool_key: str, record: dict) -> None:
52
+ blob = json.dumps(record, separators=(",", ":"))
53
+ with self.lock:
54
+ self.conn.execute(
55
+ "INSERT OR REPLACE INTO spool (spool_key, record) VALUES (?, ?)",
56
+ (spool_key, blob),
57
+ )
58
+ self.conn.commit()
59
+ self.prune()
60
+
61
+ def remove(self, spool_key: str) -> None:
62
+ with self.lock:
63
+ self.conn.execute("DELETE FROM spool WHERE spool_key = ?", (spool_key,))
64
+ self.conn.commit()
65
+
66
+ def load(self) -> list[tuple[str, dict]]:
67
+ with self.lock:
68
+ rows = self.conn.execute(
69
+ "SELECT spool_key, record FROM spool ORDER BY rowid ASC"
70
+ ).fetchall()
71
+ return [(key, json.loads(blob)) for key, blob in rows]
72
+
73
+ def prune(self) -> None:
74
+ """Drop oldest rows while the DB file exceeds the byte cap. Caller holds the lock."""
75
+ page = self.conn.execute("PRAGMA page_size").fetchone()[0]
76
+ while True:
77
+ count = self.conn.execute("PRAGMA page_count").fetchone()[0]
78
+ if count * page <= self.max_bytes:
79
+ return
80
+ victims = self.conn.execute(
81
+ "SELECT spool_key FROM spool ORDER BY rowid ASC LIMIT 64"
82
+ ).fetchall()
83
+ if not victims:
84
+ return
85
+ self.conn.executemany(
86
+ "DELETE FROM spool WHERE spool_key = ?", victims
87
+ )
88
+ self.conn.commit()
89
+
90
+ def close(self) -> None:
91
+ with self.lock:
92
+ self.conn.close()
@@ -0,0 +1,216 @@
1
+ """Sync surface, async delivery.
2
+
3
+ The public client is fully synchronous and fire-and-forget: a call validates the
4
+ payload on the *calling thread* (so a bad call raises immediately, in the
5
+ caller's stack) and then hands a serialized record to a background worker thread
6
+ that does the actual HTTP, with retry and optional durable spooling.
7
+
8
+ Idempotency: ``submission_id`` is minted once, at enqueue time, and reused on
9
+ every retry — including a replay loaded from the spool after a restart — so the
10
+ server's ``(install_id, submission_id)`` dedup turns retries into no-ops.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import queue
16
+ import threading
17
+ import time
18
+ import uuid
19
+ from dataclasses import dataclass
20
+ from typing import Callable, Optional
21
+
22
+ import httpx
23
+ from pydantic import BaseModel
24
+
25
+ from .spool import Spool
26
+
27
+ # Delivery mode → which event categories are allowed through. "minimal" mutes
28
+ # product telemetry while still letting identity/diagnostic/bootstrap flow.
29
+ MUTED_IN_MINIMAL = frozenset({"product"})
30
+
31
+
32
+ @dataclass(frozen=True)
33
+ class Route:
34
+ """One ingest endpoint. Emitted into the generated route table."""
35
+
36
+ method: str
37
+ path: str
38
+ model: type[BaseModel]
39
+ auth: bool
40
+ category: str
41
+
42
+
43
+ @dataclass
44
+ class Record:
45
+ """A validated, serialized event in flight."""
46
+
47
+ route_key: str
48
+ body: dict
49
+ submission_id: Optional[str]
50
+ spool_key: str
51
+ attempts: int = 0
52
+
53
+
54
+ DropCallback = Callable[[Record, Optional[int]], None]
55
+
56
+
57
+ class Transport:
58
+ def __init__(
59
+ self,
60
+ *,
61
+ base_url: str,
62
+ token: Optional[str],
63
+ routes: dict[str, Route],
64
+ mode: str = "full",
65
+ spool: Optional[Spool] = None,
66
+ http_client: Optional[httpx.Client] = None,
67
+ max_attempts: int = 8,
68
+ backoff_base: float = 0.5,
69
+ backoff_cap: float = 30.0,
70
+ on_drop: Optional[DropCallback] = None,
71
+ ) -> None:
72
+ self.base_url = base_url.rstrip("/")
73
+ self.token = token
74
+ self.routes = routes
75
+ self.mode = mode
76
+ self.spool = spool
77
+ self.client = http_client or httpx.Client(timeout=10.0)
78
+ self.owns_client = http_client is None
79
+ self.max_attempts = max_attempts
80
+ self.backoff_base = backoff_base
81
+ self.backoff_cap = backoff_cap
82
+ self.on_drop = on_drop
83
+
84
+ self.queue: "queue.Queue[Record]" = queue.Queue()
85
+ self.stopping = threading.Event()
86
+
87
+ # Resume anything left in the spool from a previous run (same submission_id).
88
+ if self.spool is not None:
89
+ for spool_key, rec in self.spool.load():
90
+ self.queue.put(
91
+ Record(
92
+ route_key=rec["route_key"],
93
+ body=rec["body"],
94
+ submission_id=rec.get("submission_id"),
95
+ spool_key=spool_key,
96
+ attempts=int(rec.get("attempts", 0)),
97
+ )
98
+ )
99
+
100
+ self.worker = threading.Thread(
101
+ target=self.run, name="swarm-analytics", daemon=True
102
+ )
103
+ self.worker.start()
104
+
105
+ # --- enqueue path (caller thread) --------------------------------------
106
+
107
+ def enabled_for(self, category: str) -> bool:
108
+ if self.mode == "full":
109
+ return True
110
+ return category not in MUTED_IN_MINIMAL
111
+
112
+ def build_model(self, route: Route, fields: dict) -> BaseModel:
113
+ """Validate on the caller's thread, auto-filling per-request meta."""
114
+ data = dict(fields)
115
+ model_fields = route.model.model_fields
116
+ if "ts" in model_fields:
117
+ data.setdefault("ts", time.time())
118
+ if "submission_id" in model_fields:
119
+ data.setdefault("submission_id", str(uuid.uuid4()))
120
+ return route.model(**data) # raises pydantic.ValidationError on bad input
121
+
122
+ def send(self, route_key: str, fields: dict) -> None:
123
+ route = self.routes[route_key]
124
+ if not self.enabled_for(route.category):
125
+ return
126
+ model = self.build_model(route, fields)
127
+ submission_id = getattr(model, "submission_id", None)
128
+ body = model.model_dump(by_alias=True, mode="json")
129
+ spool_key = submission_id or str(uuid.uuid4())
130
+ if self.spool is not None:
131
+ self.spool.add(spool_key, self.as_spool_record(route_key, body, submission_id, 0))
132
+ self.queue.put(
133
+ Record(route_key=route_key, body=body, submission_id=submission_id, spool_key=spool_key)
134
+ )
135
+
136
+ @staticmethod
137
+ def as_spool_record(route_key: str, body: dict, submission_id: Optional[str], attempts: int) -> dict:
138
+ return {"route_key": route_key, "body": body, "submission_id": submission_id, "attempts": attempts}
139
+
140
+ # --- delivery path (worker thread) -------------------------------------
141
+
142
+ def run(self) -> None:
143
+ while not self.stopping.is_set():
144
+ try:
145
+ rec = self.queue.get(timeout=0.2)
146
+ except queue.Empty:
147
+ continue
148
+ try:
149
+ self.deliver(rec)
150
+ finally:
151
+ self.queue.task_done()
152
+
153
+ def deliver(self, rec: Record) -> None:
154
+ route = self.routes[rec.route_key]
155
+ url = self.base_url + route.path
156
+ headers = {"Content-Type": "application/json"}
157
+ if route.auth and self.token:
158
+ headers["Authorization"] = f"Bearer {self.token}"
159
+
160
+ while True:
161
+ status = self.attempt(route.method, url, rec.body, headers)
162
+ if status is not None and 200 <= status < 300:
163
+ self.finish(rec)
164
+ return
165
+ if status is not None and status != 429 and not (500 <= status < 600):
166
+ # Permanent (4xx other than 429): bad data, not worth retrying.
167
+ self.drop(rec, status)
168
+ return
169
+ rec.attempts += 1
170
+ if rec.attempts >= self.max_attempts:
171
+ self.drop(rec, status)
172
+ return
173
+ if self.spool is not None:
174
+ self.spool.add(
175
+ rec.spool_key,
176
+ self.as_spool_record(rec.route_key, rec.body, rec.submission_id, rec.attempts),
177
+ )
178
+ delay = min(self.backoff_cap, self.backoff_base * (2 ** (rec.attempts - 1)))
179
+ # Wake early on shutdown; leave the record in the spool for next run.
180
+ if self.stopping.wait(delay):
181
+ return
182
+
183
+ def attempt(self, method: str, url: str, body: dict, headers: dict) -> Optional[int]:
184
+ """Return the HTTP status, or None for a network-level failure (retryable)."""
185
+ try:
186
+ resp = self.client.request(method, url, json=body, headers=headers)
187
+ return resp.status_code
188
+ except (httpx.TimeoutException, httpx.TransportError):
189
+ return None
190
+
191
+ def finish(self, rec: Record) -> None:
192
+ if self.spool is not None:
193
+ self.spool.remove(rec.spool_key)
194
+
195
+ def drop(self, rec: Record, status: Optional[int]) -> None:
196
+ if self.spool is not None:
197
+ self.spool.remove(rec.spool_key)
198
+ if self.on_drop is not None:
199
+ self.on_drop(rec, status)
200
+
201
+ # --- lifecycle ---------------------------------------------------------
202
+
203
+ def flush(self, timeout: Optional[float] = None) -> bool:
204
+ """Block until the queue drains (or timeout). Returns True if fully drained."""
205
+ deadline = None if timeout is None else time.monotonic() + timeout
206
+ while self.queue.unfinished_tasks > 0:
207
+ if deadline is not None and time.monotonic() >= deadline:
208
+ return False
209
+ time.sleep(0.02)
210
+ return True
211
+
212
+ def close(self) -> None:
213
+ self.stopping.set()
214
+ self.worker.join(timeout=2.0)
215
+ if self.owns_client:
216
+ self.client.close()
@@ -0,0 +1,122 @@
1
+ Metadata-Version: 2.4
2
+ Name: swarm-analytics
3
+ Version: 0.1.0
4
+ Summary: Typed, auto-generated client for the OpenSwarm product-analytics ingest API.
5
+ Author: Haik Decie
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/openswarm-ai/product-analytics-v1
8
+ Project-URL: Source, https://github.com/openswarm-ai/product-analytics-v1
9
+ Keywords: analytics,telemetry,openswarm,pydantic
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Typing :: Typed
12
+ Classifier: Intended Audience :: Developers
13
+ Requires-Python: >=3.10
14
+ Description-Content-Type: text/markdown
15
+ License-File: LICENSE
16
+ Requires-Dist: pydantic>=2.0
17
+ Requires-Dist: httpx>=0.24
18
+ Provides-Extra: dev
19
+ Requires-Dist: pytest; extra == "dev"
20
+ Dynamic: license-file
21
+
22
+ # swarm_analytics
23
+
24
+ A typed, **auto-generated** Python client for the OpenSwarm product-analytics
25
+ ingest API. It is the single network egress the desktop FastAPI backend imports to
26
+ send analytics — identity comes from a bearer token, payloads are validated
27
+ against the *exact* pydantic models the server enforces, and delivery is
28
+ fire-and-forget with background retry.
29
+
30
+ ## Why it's hard to call wrong
31
+
32
+ - **Identity is impossible to pass.** No method takes `install_id`/`user_id`; the
33
+ server resolves them from the token.
34
+ - **Per-request meta is auto-filled.** `ts` and `submission_id` never appear in a
35
+ signature — the transport stamps them (and reuses `submission_id` on every
36
+ retry for idempotency).
37
+ - **Enums stay enums.** `status`, `action`, `role`, etc. are `Literal`s. A bad
38
+ value raises `pydantic.ValidationError` synchronously, in your stack, before any
39
+ network I/O.
40
+ - **Models are vendored verbatim** from the service, so the client validates with
41
+ the same schema the server uses.
42
+
43
+ ## Install
44
+
45
+ ```bash
46
+ pip install ./sdk # from the repo root
47
+ ```
48
+
49
+ ## Usage
50
+
51
+ ```python
52
+ from swarm_analytics import AnalyticsClient, AgentMessage
53
+
54
+ # One-time bootstrap on first launch (unauthenticated, blocking)
55
+ token = AnalyticsClient.register(base_url="https://analytics.openswarm.ai", install_id=install_id)
56
+ # persist `token` in settings; reuse forever
57
+
58
+ client = AnalyticsClient(base_url="https://analytics.openswarm.ai", token=token)
59
+
60
+ client.events.app_lifecycle.opened(os="darwin", os_version="25.3.0", app_version="1.2.0")
61
+ client.events.agent.create(id="sess_123", name="Refactor auth", dashboard_id="dash_1")
62
+ client.events.agent.message(agent_id="sess_123", seq=0,
63
+ message=AgentMessage(id="m1", role="user", content="hello"))
64
+ client.events.onboarding.step(step_id="connect_provider", status="completed")
65
+ client.events.dashboard.event(dashboard_id="dash_1", action="create")
66
+ client.logs.write(tag="agent", subtag="tool", data={"name": "shell"})
67
+ client.identify.link_email(email="user@example.com")
68
+
69
+ # On shutdown
70
+ client.events.app_lifecycle.closed()
71
+ client.flush(timeout=2.0)
72
+ client.close()
73
+ ```
74
+
75
+ ### Durability (optional)
76
+
77
+ By default events live in an in-memory queue and are lost if the process dies
78
+ with deliveries pending. Pass a spool for crash/offline durability:
79
+
80
+ ```python
81
+ from swarm_analytics import SqliteSpool
82
+ client = AnalyticsClient(base_url=..., token=..., spool=SqliteSpool("service_spool.db"))
83
+ ```
84
+
85
+ ### Opt-out
86
+
87
+ ```python
88
+ client = AnalyticsClient(base_url=..., token=..., mode="minimal") # mutes product events; diagnostics still flow
89
+ ```
90
+
91
+ ## Regenerating (auto-generated — do not hand-edit `_generated/`)
92
+
93
+ The models, route table, and namespaces under `src/swarm_analytics/_generated/`
94
+ are produced from the live service. Regenerate whenever the backend's ingest
95
+ models or routes change:
96
+
97
+ ```bash
98
+ PYTHONPATH=<repo_root> python sdk/generate.py
99
+ ```
100
+
101
+ `ROUTE_SPECS` in `generate.py` (nice method name + category per endpoint) is the
102
+ only human input; it is cross-checked against the live app, so a new or removed
103
+ endpoint fails generation rather than drifting silently.
104
+
105
+ ### Drift check (CI)
106
+
107
+ ```bash
108
+ PYTHONPATH=<repo_root> python sdk/generate.py --check
109
+ ```
110
+
111
+ Exits non-zero if the committed `_generated/` output is stale. The same guard runs
112
+ as `tests/test_drift.py`.
113
+
114
+ ## Tests
115
+
116
+ ```bash
117
+ cd sdk && PYTHONPATH=<repo_root> python -m pytest tests -q
118
+ ```
119
+
120
+ Covers synchronous validation, meta auto-fill, identity-from-token, idempotent
121
+ `submission_id` reuse across retries, opt-out gating, the drift check, and an
122
+ end-to-end pass through the real FastAPI app.
@@ -0,0 +1,22 @@
1
+ swarm_analytics/__init__.py,sha256=6qtDc8zHH1mDtEIigXaNcusoJKmkgFZtOPglpHGwGOM,806
2
+ swarm_analytics/client.py,sha256=Dma3axRB1rHEWrOXHy6L15SkHmj3L_hEUXhiliy02b8,2806
3
+ swarm_analytics/errors.py,sha256=rdH8br9yzraWTmfx60JblWrgDDpTGt5wxGFvjD7EtZs,967
4
+ swarm_analytics/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
5
+ swarm_analytics/spool.py,sha256=ZnpFLmkt36Z19LdODtQh2aUiCF2Aw0khoDiMZW74caA,3407
6
+ swarm_analytics/transport.py,sha256=l3aX2cDaYr3qp7aAcXdTNUrGoGsnsWbuBex8FBcyy3c,7806
7
+ swarm_analytics/_generated/__init__.py,sha256=EuktMkT_XXZmiOXyXhQnVAtqEbEKpRRjqMiBRsEQOag,43
8
+ swarm_analytics/_generated/endpoints.py,sha256=MgRzxgzY_g-RtiXhcsHGu1tEvUfVLGNFP1X3JtdjoUg,2437
9
+ swarm_analytics/_generated/routes.py,sha256=uSaRbljGKrQ_z_QY_aT4ITrPM_NvKDr3Gft4lFZUUHM,1428
10
+ swarm_analytics/_generated/models/__init__.py,sha256=lBL8104cb1CYeTTbe5KihlZOqBFi-GyAtAmaZ67_iKg,610
11
+ swarm_analytics/_generated/models/_shared.py,sha256=zo04aZ0J4vdRAAV5dAInsj0bpeMv-9YfeqpdTx43OB4,1019
12
+ swarm_analytics/_generated/models/agent.py,sha256=rTHBo_zT4lZcZIUXbqq6PFNoZS5dWkb4iL5MoprC0Mo,2553
13
+ swarm_analytics/_generated/models/app_lifecycle.py,sha256=MqVT812KJw3WlYPkz3U2isuE8lwuk7hyg1V3q1jfeoA,512
14
+ swarm_analytics/_generated/models/dashboard.py,sha256=Zsg_EKuLkp-bU5dfa2HUcN8y_iIKmMfXMTsJ3hp2RLs,459
15
+ swarm_analytics/_generated/models/identify.py,sha256=FFCn6vpWylAoBAzw6Ay7hr8EIJTjdurVfG5FpR6R9jY,639
16
+ swarm_analytics/_generated/models/logs.py,sha256=2LfYzU3VD8X4DlrOQtdXVnaGU2tNqGRBrGoiDwtUAK0,467
17
+ swarm_analytics/_generated/models/onboarding.py,sha256=aCIwI_pCOgGwc-SypWhcTO0xFns2_vg7CT-UeEM23JY,386
18
+ swarm_analytics-0.1.0.dist-info/licenses/LICENSE,sha256=6eKzD82yvZAYnQ2RgZoM-3nnkSqIP9Sq1fF8zm0O6QU,1067
19
+ swarm_analytics-0.1.0.dist-info/METADATA,sha256=YWteQLJrysGsZmhTon1-Yduh40yFbTq0RmDNorLQoSk,4345
20
+ swarm_analytics-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
21
+ swarm_analytics-0.1.0.dist-info/top_level.txt,sha256=_GZt1gb77qObV_e2nb7vWf-LMxDdDC124dr_Xc1taDI,16
22
+ swarm_analytics-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Haik Decie
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ swarm_analytics