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.
- swarm_analytics/__init__.py +32 -0
- swarm_analytics/_generated/__init__.py +1 -0
- swarm_analytics/_generated/endpoints.py +71 -0
- swarm_analytics/_generated/models/__init__.py +23 -0
- swarm_analytics/_generated/models/_shared.py +31 -0
- swarm_analytics/_generated/models/agent.py +68 -0
- swarm_analytics/_generated/models/app_lifecycle.py +15 -0
- swarm_analytics/_generated/models/dashboard.py +16 -0
- swarm_analytics/_generated/models/identify.py +22 -0
- swarm_analytics/_generated/models/logs.py +17 -0
- swarm_analytics/_generated/models/onboarding.py +16 -0
- swarm_analytics/_generated/routes.py +22 -0
- swarm_analytics/client.py +83 -0
- swarm_analytics/errors.py +32 -0
- swarm_analytics/py.typed +0 -0
- swarm_analytics/spool.py +92 -0
- swarm_analytics/transport.py +216 -0
- swarm_analytics-0.1.0.dist-info/METADATA +122 -0
- swarm_analytics-0.1.0.dist-info/RECORD +22 -0
- swarm_analytics-0.1.0.dist-info/WHEEL +5 -0
- swarm_analytics-0.1.0.dist-info/licenses/LICENSE +21 -0
- swarm_analytics-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -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
|
+
"""
|
swarm_analytics/py.typed
ADDED
|
File without changes
|
swarm_analytics/spool.py
ADDED
|
@@ -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,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
|