centive-sdk 2.1.0.dev3__py3-none-any.whl → 2.2.0.dev4__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.
centive_sdk/__init__.py CHANGED
@@ -14,21 +14,27 @@ from .exceptions import (
14
14
  from .models.requests import (
15
15
  MessageHistoryEvent,
16
16
  MessageStreamEvent,
17
+ ProductEventBatchRequest,
17
18
  SaveMessagesRequest,
18
19
  SessionEndEvent,
19
20
  SessionMessage,
20
21
  SessionMetadata,
21
22
  StreamMessage,
23
+ TelemetryEvent,
24
+ TelemetryFrame,
25
+ TelemetryUserBatch,
22
26
  ToolMappingRequest,
23
27
  TriggerSessionRequest,
24
28
  )
25
29
  from .models.responses import (
26
30
  PauseStatusResponse,
31
+ ProductEventIngestResponse,
27
32
  SaveMessagesResponse,
28
33
  ToolMappingResponse,
29
34
  TriggerSessionResponse,
30
35
  )
31
36
  from .resources.message_accumulator import MessageAccumulator
37
+ from .resources.telemetry_batcher import TelemetryBatcher
32
38
  from .resources.websocket_server import WebSocketServer
33
39
 
34
40
  try:
@@ -52,14 +58,20 @@ __all__ = [
52
58
  "MessageHistoryEvent",
53
59
  "MessageStreamEvent",
54
60
  "SessionEndEvent",
61
+ "TelemetryEvent",
62
+ "TelemetryFrame",
63
+ "TelemetryUserBatch",
64
+ "ProductEventBatchRequest",
55
65
  # Response Models
56
66
  "ToolMappingResponse",
57
67
  "TriggerSessionResponse",
58
68
  "SaveMessagesResponse",
59
69
  "PauseStatusResponse",
70
+ "ProductEventIngestResponse",
60
71
  # Resources
61
72
  "WebSocketServer",
62
73
  "MessageAccumulator",
74
+ "TelemetryBatcher",
63
75
  # Exceptions
64
76
  "CentiveError",
65
77
  "AuthError",
centive_sdk/_logging.py CHANGED
@@ -72,6 +72,11 @@ def redact_request_data(data: dict) -> dict:
72
72
  if "company_name" in redacted:
73
73
  redacted["company_name"] = mask_string(str(redacted["company_name"]))
74
74
 
75
+ # Page paths and titles can carry identifiers from the customer's app.
76
+ for path_field in ("page_path", "page_title", "path", "title", "referrer_path"):
77
+ if path_field in redacted:
78
+ redacted[path_field] = mask_string(str(redacted[path_field]))
79
+
75
80
  for secret_field in ("api_key", "token", "connection_token", "ws_handshake_secret"):
76
81
  if secret_field in redacted:
77
82
  redacted[secret_field] = "[REDACTED]"
@@ -7,6 +7,8 @@ from ._logging import safe_log
7
7
  from .config import ClientConfig
8
8
  from .models.responses import PauseStatusResponse
9
9
  from .resources.async_sessions import AsyncSessions
10
+ from .resources.async_telemetry import AsyncTelemetry
11
+ from .resources.telemetry_batcher import TelemetryBatcher
10
12
  from .resources.websocket_server import WebSocketServer
11
13
 
12
14
 
@@ -43,8 +45,10 @@ class AsyncCentiveClient:
43
45
 
44
46
  Pass on_telemetry=<callable> to receive page-telemetry frames from the FE
45
47
  SDK: on_telemetry(user_id: str, frame: dict), sync or async, with the
46
- events at frame["events"]. The SDK does not store them — without a
47
- callback they are acknowledged and dropped.
48
+ events at frame["events"]. By default the SDK also forwards these
49
+ events to Centive (POST /anam/events); the callback is an additional
50
+ sink. With telemetry_enabled=False and no callback, frames are
51
+ acknowledged and dropped.
48
52
  """
49
53
  if api_key is not None:
50
54
  options["api_key"] = api_key
@@ -57,6 +61,10 @@ class AsyncCentiveClient:
57
61
  # **options and is read out here. ClientConfig ignores unknown keys.
58
62
  self._on_telemetry = options.get("on_telemetry")
59
63
  self.sessions = AsyncSessions(self._http_client, self.config, self._logger)
64
+ self._telemetry_batcher = TelemetryBatcher(
65
+ send=self.sessions.send_events, config=self.config, logger=self._logger
66
+ )
67
+ self.telemetry = AsyncTelemetry(self._telemetry_batcher, self._logger)
60
68
  self._ws_server: Optional[WebSocketServer] = None
61
69
  self._last_pause_status: Optional[PauseStatusResponse] = None
62
70
  # One process serves many users; serialize server startup so concurrent
@@ -136,15 +144,38 @@ class AsyncCentiveClient:
136
144
  sessions=self.sessions,
137
145
  config=self.config,
138
146
  logger=self._logger,
147
+ telemetry=self._telemetry_batcher,
139
148
  on_telemetry=self._on_telemetry,
140
149
  )
141
150
  # Publish only after a successful bind, so a failed start does
142
151
  # not leave a dead server in place and wedge every later call.
143
152
  await server.start(host=ws_host, port=ws_port, **ws_options)
144
153
  self._ws_server = server
154
+ self._telemetry_batcher.on_context = self._relay_live_context
155
+ self._telemetry_batcher.start()
145
156
 
146
157
  return self._ws_server.register_user(user_id)
147
158
 
159
+ async def _relay_live_context(self, user_id: str, entry: dict) -> int:
160
+ """Pushes a Centive context entry to the user's browser as a C4 frame.
161
+
162
+ The browser SDK calls anamClient.addContext with it; Anam has no
163
+ server-side way to inform a running session. Only the connection bound
164
+ to this user receives it (send_to_user), never a broadcast.
165
+ """
166
+ server = self._ws_server
167
+ if server is None or not server.is_running:
168
+ return 0
169
+ frame = {
170
+ "type": "context",
171
+ "schema": 1,
172
+ "content": str(entry.get("content", ""))[:1000],
173
+ "source": "centive",
174
+ }
175
+ if entry.get("expires_at"):
176
+ frame["expires_at"] = entry["expires_at"]
177
+ return await server.send_to_user(user_id, frame)
178
+
148
179
  async def get_aria_status(self, user_id: str) -> PauseStatusResponse:
149
180
  """Checks if Aria is paused for a user (for UI display purposes).
150
181
 
@@ -179,6 +210,9 @@ class AsyncCentiveClient:
179
210
  await self._ws_server.stop()
180
211
  self._ws_server = None
181
212
 
213
+ # Flush queued page events before the HTTP client goes away.
214
+ await self._telemetry_batcher.stop()
215
+
182
216
  await self._http_client.aclose()
183
217
 
184
218
  async def __aenter__(self) -> "AsyncCentiveClient":
centive_sdk/config.py CHANGED
@@ -38,6 +38,7 @@ class ClientConfig(BaseModel):
38
38
  trigger_session_path: str = "/anam/trigger-session"
39
39
  save_messages_path: str = "/anam/save-messages"
40
40
  pause_status_path: str = "/anam/pause-status"
41
+ events_path: str = "/anam/events"
41
42
  timeout_seconds: float = 10.0
42
43
  max_retries: int = 3
43
44
  initial_retry_delay: float = 0.5
@@ -98,6 +99,19 @@ class ClientConfig(BaseModel):
98
99
  # False to fail closed and withhold connections instead.
99
100
  pause_check_fail_open: bool = True
100
101
 
102
+ # Product events (page tracking) forwarded from FE "telemetry" frames to
103
+ # POST /anam/events. Contract: CentiveAI docs/product-events/CONTRACTS.md.
104
+ telemetry_enabled: bool = True
105
+ # Flush when this many events are queued, or after the interval, whichever first.
106
+ telemetry_flush_max_events: int = 50
107
+ telemetry_flush_interval_seconds: float = 2.0
108
+ # Bounded in-memory queue; oldest events are dropped (and counted) when full.
109
+ telemetry_max_queue_events: int = 5000
110
+ # Per-frame caps (C1 limits) live above as max_telemetry_* — they are
111
+ # shared with the validation the host-callback path uses.
112
+ # Telemetry frames accepted per websocket connection per minute (0 disables).
113
+ telemetry_rate_per_connection_per_minute: int = 120
114
+
101
115
  incremental_save_enabled: bool = Field(
102
116
  default=False,
103
117
  description="enable incremental message saving (sends messages to API immediately as they arrive)"
@@ -1,10 +1,27 @@
1
- from .requests import ToolMappingRequest, TriggerSessionRequest
2
- from .responses import PauseStatusResponse, ToolMappingResponse, TriggerSessionResponse
1
+ from .requests import (
2
+ ProductEventBatchRequest,
3
+ TelemetryEvent,
4
+ TelemetryFrame,
5
+ TelemetryUserBatch,
6
+ ToolMappingRequest,
7
+ TriggerSessionRequest,
8
+ )
9
+ from .responses import (
10
+ PauseStatusResponse,
11
+ ProductEventIngestResponse,
12
+ ToolMappingResponse,
13
+ TriggerSessionResponse,
14
+ )
3
15
 
4
16
  __all__ = [
5
17
  "ToolMappingRequest",
6
18
  "TriggerSessionRequest",
19
+ "TelemetryEvent",
20
+ "TelemetryFrame",
21
+ "TelemetryUserBatch",
22
+ "ProductEventBatchRequest",
7
23
  "ToolMappingResponse",
8
24
  "TriggerSessionResponse",
9
25
  "PauseStatusResponse",
26
+ "ProductEventIngestResponse",
10
27
  ]
@@ -1,6 +1,6 @@
1
- from typing import List, Literal, Optional
1
+ from typing import Any, Dict, List, Literal, Optional
2
2
 
3
- from pydantic import BaseModel, Field
3
+ from pydantic import BaseModel, ConfigDict, Field, field_validator
4
4
 
5
5
 
6
6
  class ToolMappingRequest(BaseModel):
@@ -86,3 +86,79 @@ class SessionEndEvent(BaseModel):
86
86
  type: Literal["session_end"] = "session_end"
87
87
  session_id: str = Field(..., description="session ID")
88
88
  user_id: str = Field(..., description="user ID")
89
+
90
+
91
+ # ---------------------------------------------------------------------------
92
+ # Product events (page tracking). Contract: CentiveAI docs/product-events/CONTRACTS.md
93
+ # ---------------------------------------------------------------------------
94
+
95
+ TELEMETRY_MAX_PROPERTIES_BYTES = 4 * 1024
96
+ TELEMETRY_MAX_PATH_CHARS = 2048
97
+
98
+
99
+ class TelemetryPage(BaseModel):
100
+ """page fields of a C1 event."""
101
+
102
+ model_config = ConfigDict(extra="ignore")
103
+
104
+ path: str = Field(..., min_length=1, max_length=TELEMETRY_MAX_PATH_CHARS)
105
+ pattern: Optional[str] = Field(default=None, max_length=TELEMETRY_MAX_PATH_CHARS)
106
+ title: Optional[str] = Field(default=None, max_length=512)
107
+ referrer_path: Optional[str] = Field(default=None, max_length=TELEMETRY_MAX_PATH_CHARS)
108
+
109
+
110
+ class TelemetryEvent(BaseModel):
111
+ """one C1 event as produced by the browser SDK; forwarded to Centive unchanged."""
112
+
113
+ model_config = ConfigDict(extra="ignore")
114
+
115
+ client_event_id: str = Field(..., min_length=1, max_length=64)
116
+ event_type: str = Field(..., min_length=1, max_length=64)
117
+ occurred_at: str = Field(..., description="iso 8601 timestamp with timezone")
118
+ browser_session_id: Optional[str] = Field(default=None, max_length=64)
119
+ page: Optional[TelemetryPage] = None
120
+ duration_ms: Optional[int] = Field(default=None, ge=0)
121
+ detection: Optional[str] = Field(default=None, max_length=32)
122
+ properties: Dict[str, Any] = Field(default_factory=dict)
123
+
124
+ @field_validator("properties")
125
+ @classmethod
126
+ def _bound_properties(cls, value: Dict[str, Any]) -> Dict[str, Any]:
127
+ import json
128
+
129
+ if len(json.dumps(value, separators=(",", ":"), default=str)) > TELEMETRY_MAX_PROPERTIES_BYTES:
130
+ raise ValueError("properties_too_large")
131
+ return value
132
+
133
+
134
+ class TelemetryFrame(BaseModel):
135
+ """event payload for type="telemetry" from FE SDK (C1)."""
136
+
137
+ model_config = ConfigDict(extra="ignore")
138
+
139
+ type: Literal["telemetry"] = "telemetry"
140
+ schema_version: int = Field(default=1, alias="schema")
141
+ user_id: Optional[str] = Field(default=None, max_length=255)
142
+ sdk: Optional[str] = Field(default=None, max_length=64)
143
+ events: List[TelemetryEvent] = Field(..., min_length=1)
144
+
145
+
146
+ class TelemetryUserBatch(BaseModel):
147
+ """events for one end user inside a C2 request."""
148
+
149
+ external_user_id: str = Field(..., min_length=1, max_length=255)
150
+ client_sdk: Optional[str] = Field(default=None, max_length=64)
151
+ events: List[TelemetryEvent] = Field(..., min_length=1)
152
+
153
+
154
+ class ProductEventBatchRequest(BaseModel):
155
+ """request payload for POST /anam/events (C2)."""
156
+
157
+ schema_version: int = Field(default=1, serialization_alias="schema")
158
+ sdk_version: Optional[str] = None
159
+ batches: List[TelemetryUserBatch]
160
+ idempotency_key: Optional[str] = Field(default=None, exclude=True)
161
+
162
+ @property
163
+ def total_events(self) -> int:
164
+ return sum(len(b.events) for b in self.batches)
@@ -59,3 +59,16 @@ class SaveMessagesResponse(BaseModel):
59
59
  time_taken: float = 0.0
60
60
  error: Optional[str] = None
61
61
  raw: Optional[Dict[str, Any]] = None
62
+
63
+
64
+ class ProductEventIngestResponse(BaseModel):
65
+ """response from POST /anam/events (202)."""
66
+
67
+ accepted: int = 0
68
+ rejected: int = 0
69
+ rejections: list = Field(default_factory=list)
70
+ # Context for users with a live Aria session; relayed to the browser as a
71
+ # "context" frame (C4) for anamClient.addContext. Entries look like
72
+ # {"external_user_id", "content", "expires_at"}.
73
+ context: list = Field(default_factory=list)
74
+ raw: Optional[Dict[str, Any]] = None
@@ -8,9 +8,15 @@ from .._logging import safe_log
8
8
  from .._retry import calculate_backoff, extract_retry_after, should_retry
9
9
  from ..config import ClientConfig
10
10
  from ..exceptions import AuthError, NetworkError, RateLimitError, ServerError, ValidationError
11
- from ..models.requests import SaveMessagesRequest, ToolMappingRequest, TriggerSessionRequest
11
+ from ..models.requests import (
12
+ ProductEventBatchRequest,
13
+ SaveMessagesRequest,
14
+ ToolMappingRequest,
15
+ TriggerSessionRequest,
16
+ )
12
17
  from ..models.responses import (
13
18
  PauseStatusResponse,
19
+ ProductEventIngestResponse,
14
20
  SaveMessagesResponse,
15
21
  ToolMappingResponse,
16
22
  TriggerSessionResponse,
@@ -191,6 +197,35 @@ class AsyncSessions:
191
197
  raw=data,
192
198
  )
193
199
 
200
+ async def send_events(self, input: ProductEventBatchRequest) -> ProductEventIngestResponse:
201
+ """Sends a batch of product events (page views) to Centive.
202
+
203
+ Contract C2 in CentiveAI docs/product-events/CONTRACTS.md. A 202 is
204
+ final: the ``rejections`` in the response are not retryable. 429 and
205
+ 5xx are retried with backoff like every other call; 400/413/422 raise
206
+ ``ValidationError`` and 401 raises ``AuthError``.
207
+ """
208
+ url = join_url(self._config.base_url, self._config.events_path)
209
+ headers = build_headers(self._config.api_key, idempotency_key=input.idempotency_key)
210
+ payload = input.model_dump(mode="json", by_alias=True)
211
+ if payload.get("sdk_version") is None:
212
+ from .. import __version__
213
+
214
+ payload["sdk_version"] = f"python/{__version__}"
215
+
216
+ response = await self._make_request(url, headers, payload)
217
+ try:
218
+ data = response.json()
219
+ except ValueError:
220
+ data = {}
221
+ return ProductEventIngestResponse(
222
+ accepted=int(data.get("accepted", 0)),
223
+ rejected=int(data.get("rejected", 0)),
224
+ rejections=list(data.get("rejections", []) or []),
225
+ context=list(data.get("context", []) or []),
226
+ raw=data,
227
+ )
228
+
194
229
  async def get_pause_status(self, user_id: str) -> PauseStatusResponse:
195
230
  """Checks if Aria is paused for a specific user.
196
231
 
@@ -381,7 +416,9 @@ class AsyncSessions:
381
416
  error_detail = self._extract_error_message(response)
382
417
  raise AuthError(f"Authentication failed: {error_detail}", request_id=request_id)
383
418
 
384
- elif response.status_code in [400, 422]:
419
+ elif response.status_code in [400, 413, 422]:
420
+ # 413 is "payload over the API's size caps": retrying the
421
+ # same body cannot succeed, so it is a validation failure.
385
422
  error_detail = self._extract_error_message(response)
386
423
  raise ValidationError(
387
424
  f"Validation error: {error_detail}", request_id=request_id
@@ -0,0 +1,65 @@
1
+ """Server-side entry point for product events.
2
+
3
+ Most events arrive from the browser through the websocket ``telemetry`` frame.
4
+ This resource lets the customer's backend emit its own events (for example
5
+ ``invoice_paid``) into the same queue, and exposes queue statistics.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import uuid
11
+ from datetime import datetime, timezone
12
+ from typing import Any, Dict, Optional
13
+
14
+ from .._logging import safe_log
15
+ from ..models.requests import TelemetryEvent
16
+ from .telemetry_batcher import TelemetryBatcher
17
+
18
+
19
+ class AsyncTelemetry:
20
+ def __init__(self, batcher: TelemetryBatcher, logger: Optional[Any] = None):
21
+ self._batcher = batcher
22
+ self._logger = logger
23
+
24
+ def track(
25
+ self,
26
+ user_id: str,
27
+ event_type: str,
28
+ properties: Optional[Dict[str, Any]] = None,
29
+ *,
30
+ occurred_at: Optional[datetime] = None,
31
+ client_event_id: Optional[str] = None,
32
+ page_path: Optional[str] = None,
33
+ ) -> bool:
34
+ """Queues one server-side event for ``user_id``.
35
+
36
+ ``user_id`` must come from your own authentication, exactly like
37
+ ``tool_mapping``. Events for users Centive cannot link to an account are
38
+ dropped by the API, not stored. Returns False if the event was dropped
39
+ locally (telemetry disabled or queue full).
40
+ """
41
+ when = occurred_at or datetime.now(timezone.utc)
42
+ if when.tzinfo is None:
43
+ when = when.replace(tzinfo=timezone.utc)
44
+ event = TelemetryEvent(
45
+ client_event_id=client_event_id or str(uuid.uuid4()),
46
+ event_type=event_type,
47
+ occurred_at=when.isoformat(),
48
+ page={"path": page_path} if page_path else None,
49
+ detection="manual",
50
+ properties=properties or {},
51
+ )
52
+ accepted, dropped = self._batcher.enqueue(user_id, "python/server", [event])
53
+ if dropped:
54
+ safe_log(self._logger, "debug", "server-side telemetry event dropped", {"event_type": event_type})
55
+ return accepted > 0 and dropped == 0
56
+
57
+ async def flush(self) -> None:
58
+ """Sends everything queued right now. Normally not needed; the batcher
59
+ flushes every ``telemetry_flush_interval_seconds``."""
60
+ if not self._batcher.is_running:
61
+ self._batcher.start()
62
+ await self._batcher.flush()
63
+
64
+ def stats(self) -> Dict[str, Any]:
65
+ return self._batcher.stats()
@@ -0,0 +1,341 @@
1
+ """Buffers product events (page views) from FE connections and forwards them to
2
+ Centive in batches.
3
+
4
+ Contract: CentiveAI ``docs/product-events/CONTRACTS.md`` (C1 in, C2 out).
5
+
6
+ Design points:
7
+ - Bounded queue. When full, the oldest events are dropped and counted; the
8
+ websocket handler is never blocked.
9
+ - Flush on ``telemetry_flush_max_events`` or every
10
+ ``telemetry_flush_interval_seconds``, whichever comes first.
11
+ - Own ``CircuitBreaker``: a flaky events endpoint must not trip the breaker
12
+ that guards transcript saves, and vice versa.
13
+ - ``Idempotency-Key`` per flush; the API dedupes on ``client_event_id`` so a
14
+ retried flush can never double count.
15
+ - Retryable failures (429 / 5xx / network) re-queue the batch at the front,
16
+ bounded by the same queue cap. Non-retryable failures (400 / 401 / 413 / 422)
17
+ drop the batch and are counted.
18
+ - A 202 is final: its ``rejections`` are recorded in stats, never retried.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import asyncio
24
+ import time
25
+ import uuid
26
+ from collections import deque
27
+ from dataclasses import dataclass, field
28
+ from typing import Any, Awaitable, Callable, Deque, Dict, List, Optional, Tuple
29
+
30
+ from .._logging import safe_log
31
+ from ..config import ClientConfig
32
+ from ..exceptions import AuthError, NetworkError, RateLimitError, ServerError, ValidationError
33
+ from ..models.requests import ProductEventBatchRequest, TelemetryEvent, TelemetryUserBatch
34
+ from ..models.responses import ProductEventIngestResponse
35
+ from .message_accumulator import CircuitBreaker
36
+
37
+ SendCallable = Callable[[ProductEventBatchRequest], Awaitable[ProductEventIngestResponse]]
38
+ # (external_user_id, context entry dict) -> awaitable; set by the client to
39
+ # relay live-session context to the user's browser (C4 frame).
40
+ ContextCallable = Callable[[str, Dict[str, Any]], Awaitable[Any]]
41
+
42
+ # (external_user_id, client_sdk, event)
43
+ _Queued = Tuple[str, Optional[str], TelemetryEvent]
44
+
45
+
46
+ @dataclass
47
+ class TelemetryStats:
48
+ enqueued: int = 0
49
+ dropped_queue_full: int = 0
50
+ sent: int = 0
51
+ accepted: int = 0
52
+ rejected: int = 0
53
+ dropped_non_retryable: int = 0
54
+ flush_failures: int = 0
55
+ rejections_by_reason: Dict[str, int] = field(default_factory=dict)
56
+ last_error: Optional[str] = None
57
+ context_received: int = 0
58
+ context_delivered: int = 0
59
+
60
+ def as_dict(self) -> Dict[str, Any]:
61
+ return {
62
+ "enqueued": self.enqueued,
63
+ "dropped_queue_full": self.dropped_queue_full,
64
+ "sent": self.sent,
65
+ "accepted": self.accepted,
66
+ "rejected": self.rejected,
67
+ "dropped_non_retryable": self.dropped_non_retryable,
68
+ "flush_failures": self.flush_failures,
69
+ "rejections_by_reason": dict(self.rejections_by_reason),
70
+ "last_error": self.last_error,
71
+ "context_received": self.context_received,
72
+ "context_delivered": self.context_delivered,
73
+ }
74
+
75
+
76
+ class TelemetryBatcher:
77
+ def __init__(
78
+ self,
79
+ send: SendCallable,
80
+ config: ClientConfig,
81
+ logger: Optional[Any] = None,
82
+ ):
83
+ self._send = send
84
+ self._config = config
85
+ self._logger = logger
86
+ self._queue: Deque[_Queued] = deque()
87
+ self._stats = TelemetryStats()
88
+ self._breaker = CircuitBreaker(
89
+ failure_threshold=config.circuit_breaker_threshold,
90
+ recovery_timeout_seconds=config.circuit_breaker_recovery_seconds,
91
+ logger=logger,
92
+ )
93
+ self._flush_task: Optional[asyncio.Task] = None
94
+ self._flush_lock = asyncio.Lock()
95
+ self._wake = asyncio.Event()
96
+ self._running = False
97
+ self._auth_error_logged = False
98
+ self.on_context: Optional[ContextCallable] = None
99
+
100
+ # ------------------------------------------------------------------ state
101
+
102
+ @property
103
+ def is_running(self) -> bool:
104
+ return self._running
105
+
106
+ @property
107
+ def queue_depth(self) -> int:
108
+ return len(self._queue)
109
+
110
+ @property
111
+ def circuit_breaker(self) -> CircuitBreaker:
112
+ return self._breaker
113
+
114
+ def stats(self) -> Dict[str, Any]:
115
+ data = self._stats.as_dict()
116
+ data["queue_depth"] = len(self._queue)
117
+ data["circuit_breaker"] = self._breaker.state
118
+ return data
119
+
120
+ # -------------------------------------------------------------- lifecycle
121
+
122
+ def start(self) -> None:
123
+ if self._running:
124
+ return
125
+ self._running = True
126
+ self._flush_task = asyncio.create_task(self._flush_loop())
127
+
128
+ async def stop(self) -> None:
129
+ """Stops the flush loop and sends whatever is still queued."""
130
+ if not self._running:
131
+ return
132
+ self._running = False
133
+ self._wake.set()
134
+ if self._flush_task is not None:
135
+ self._flush_task.cancel()
136
+ try:
137
+ await self._flush_task
138
+ except (asyncio.CancelledError, Exception):
139
+ pass
140
+ self._flush_task = None
141
+ try:
142
+ await self.flush()
143
+ except Exception as e: # never let shutdown raise because of telemetry
144
+ safe_log(self._logger, "warning", f"telemetry flush on stop failed: {e}")
145
+
146
+ # ---------------------------------------------------------------- enqueue
147
+
148
+ def enqueue(
149
+ self, external_user_id: str, client_sdk: Optional[str], events: List[TelemetryEvent]
150
+ ) -> Tuple[int, int]:
151
+ """Queues events for the given user. Returns (accepted, dropped).
152
+
153
+ Never blocks. Drops the *oldest* queued events when the cap is hit so
154
+ the newest navigation is the one that survives.
155
+ """
156
+ if not self._config.telemetry_enabled:
157
+ return 0, len(events)
158
+ cap = self._config.telemetry_max_queue_events
159
+ dropped = 0
160
+ for event in events:
161
+ if len(self._queue) >= cap:
162
+ self._queue.popleft()
163
+ dropped += 1
164
+ self._queue.append((external_user_id, client_sdk, event))
165
+ self._stats.enqueued += len(events)
166
+ self._stats.dropped_queue_full += dropped
167
+ if dropped:
168
+ safe_log(
169
+ self._logger,
170
+ "warning",
171
+ "telemetry queue full, dropped oldest events",
172
+ {"dropped": dropped, "cap": cap},
173
+ )
174
+ if len(self._queue) >= self._config.telemetry_flush_max_events:
175
+ self._wake.set()
176
+ return len(events), dropped
177
+
178
+ # ------------------------------------------------------------------ flush
179
+
180
+ async def _flush_loop(self) -> None:
181
+ interval = self._config.telemetry_flush_interval_seconds
182
+ while self._running:
183
+ try:
184
+ await asyncio.wait_for(self._wake.wait(), timeout=interval)
185
+ except asyncio.TimeoutError:
186
+ pass
187
+ self._wake.clear()
188
+ if not self._running:
189
+ break
190
+ try:
191
+ await self.flush()
192
+ except Exception as e: # the loop must survive anything
193
+ safe_log(self._logger, "error", f"telemetry flush loop error: {e}")
194
+
195
+ async def flush(self) -> None:
196
+ """Sends queued events, one request per ``telemetry_flush_max_events``."""
197
+ async with self._flush_lock:
198
+ while self._queue:
199
+ if self._breaker.is_open:
200
+ safe_log(
201
+ self._logger,
202
+ "debug",
203
+ "telemetry circuit breaker open, holding queue",
204
+ {"queue_depth": len(self._queue)},
205
+ )
206
+ return
207
+ batch: List[_Queued] = []
208
+ limit = self._config.telemetry_flush_max_events
209
+ while self._queue and len(batch) < limit:
210
+ batch.append(self._queue.popleft())
211
+ if not await self._send_batch(batch):
212
+ return
213
+
214
+ async def _send_batch(self, batch: List[_Queued]) -> bool:
215
+ """Returns True to keep flushing, False to stop for now."""
216
+ grouped: Dict[Tuple[str, Optional[str]], List[TelemetryEvent]] = {}
217
+ for user_id, client_sdk, event in batch:
218
+ grouped.setdefault((user_id, client_sdk), []).append(event)
219
+ request = ProductEventBatchRequest(
220
+ batches=[
221
+ TelemetryUserBatch(external_user_id=uid, client_sdk=sdk, events=events)
222
+ for (uid, sdk), events in grouped.items()
223
+ ],
224
+ idempotency_key=str(uuid.uuid4()),
225
+ )
226
+ try:
227
+ response = await self._send(request)
228
+ except (RateLimitError, ServerError, NetworkError) as e:
229
+ # Retryable: put the batch back at the front and back off via the breaker.
230
+ self._breaker.record_failure()
231
+ self._stats.flush_failures += 1
232
+ self._stats.last_error = type(e).__name__
233
+ self._requeue_front(batch)
234
+ safe_log(
235
+ self._logger,
236
+ "warning",
237
+ f"telemetry flush failed, will retry: {type(e).__name__}",
238
+ {"events": len(batch), "queue_depth": len(self._queue)},
239
+ )
240
+ return False
241
+ except AuthError as e:
242
+ self._stats.dropped_non_retryable += len(batch)
243
+ self._stats.last_error = "AuthError"
244
+ if not self._auth_error_logged:
245
+ self._auth_error_logged = True
246
+ safe_log(self._logger, "error", f"telemetry rejected: invalid API key ({e})")
247
+ return False
248
+ except ValidationError as e:
249
+ # 400 / 413 / 422: our payload was wrong; retrying cannot help.
250
+ self._stats.dropped_non_retryable += len(batch)
251
+ self._stats.last_error = "ValidationError"
252
+ safe_log(
253
+ self._logger,
254
+ "warning",
255
+ f"telemetry batch rejected by API, dropped: {e}",
256
+ {"events": len(batch)},
257
+ )
258
+ return True
259
+ except Exception as e: # unknown: treat as retryable but bounded by the breaker
260
+ self._breaker.record_failure()
261
+ self._stats.flush_failures += 1
262
+ self._stats.last_error = type(e).__name__
263
+ self._requeue_front(batch)
264
+ safe_log(self._logger, "error", f"telemetry flush unexpected error: {e}")
265
+ return False
266
+
267
+ self._breaker.record_success()
268
+ await self._relay_context(response)
269
+ self._stats.sent += len(batch)
270
+ self._stats.accepted += response.accepted
271
+ self._stats.rejected += response.rejected
272
+ for rejection in response.rejections:
273
+ reason = rejection.get("reason", "unknown")
274
+ self._stats.rejections_by_reason[reason] = (
275
+ self._stats.rejections_by_reason.get(reason, 0) + int(rejection.get("count", 0))
276
+ )
277
+ if response.rejected:
278
+ safe_log(
279
+ self._logger,
280
+ "debug",
281
+ "telemetry events rejected by API (final, not retried)",
282
+ {
283
+ "accepted": response.accepted,
284
+ "rejected": response.rejected,
285
+ "reasons": sorted({r.get("reason") for r in response.rejections}),
286
+ },
287
+ )
288
+ return True
289
+
290
+ async def _relay_context(self, response: ProductEventIngestResponse) -> None:
291
+ """Hands live-session context entries to the client's relay (C4)."""
292
+ entries = getattr(response, "context", None) or []
293
+ if not entries:
294
+ return
295
+ self._stats.context_received += len(entries)
296
+ if self.on_context is None:
297
+ return
298
+ for entry in entries:
299
+ user_id = entry.get("external_user_id") if isinstance(entry, dict) else None
300
+ if not user_id or not entry.get("content"):
301
+ continue
302
+ try:
303
+ delivered = await self.on_context(user_id, entry)
304
+ if delivered:
305
+ self._stats.context_delivered += 1
306
+ except Exception as e: # relay problems must never break flushing
307
+ safe_log(self._logger, "debug", f"context relay failed: {e}")
308
+
309
+ def _requeue_front(self, batch: List[_Queued]) -> None:
310
+ cap = self._config.telemetry_max_queue_events
311
+ for item in reversed(batch):
312
+ if len(self._queue) >= cap:
313
+ # Queue filled while we were sending; the batch is older than
314
+ # what is queued, so it is the part that gives way.
315
+ self._stats.dropped_queue_full += 1
316
+ continue
317
+ self._queue.appendleft(item)
318
+
319
+
320
+ class ConnectionRateLimiter:
321
+ """Token bucket per websocket connection for telemetry frames."""
322
+
323
+ def __init__(self, per_minute: int):
324
+ self._per_minute = per_minute
325
+ self._buckets: Dict[Any, Tuple[float, float]] = {} # key -> (tokens, updated)
326
+
327
+ def allow(self, key: Any, now: Optional[float] = None) -> bool:
328
+ if self._per_minute <= 0:
329
+ return True
330
+ now = time.monotonic() if now is None else now
331
+ rate = self._per_minute / 60.0
332
+ tokens, updated = self._buckets.get(key, (float(self._per_minute), now))
333
+ tokens = min(float(self._per_minute), tokens + max(0.0, now - updated) * rate)
334
+ if tokens >= 1.0:
335
+ self._buckets[key] = (tokens - 1.0, now)
336
+ return True
337
+ self._buckets[key] = (tokens, now)
338
+ return False
339
+
340
+ def forget(self, key: Any) -> None:
341
+ self._buckets.pop(key, None)
@@ -16,6 +16,7 @@ from ..models.requests import (
16
16
  SaveMessagesRequest,
17
17
  SessionMessage,
18
18
  SessionMetadata,
19
+ TelemetryEvent,
19
20
  TriggerSessionRequest,
20
21
  )
21
22
 
@@ -23,6 +24,7 @@ if TYPE_CHECKING:
23
24
  from .async_sessions import AsyncSessions
24
25
 
25
26
  from .message_accumulator import MessageAccumulator
27
+ from .telemetry_batcher import ConnectionRateLimiter, TelemetryBatcher
26
28
 
27
29
  # Short identifier fields on a telemetry event. Not a config knob: the frame
28
30
  # byte cap already bounds the whole payload, this only stops one field from
@@ -53,15 +55,20 @@ class WebSocketServer:
53
55
  sessions: "AsyncSessions",
54
56
  config: ClientConfig,
55
57
  logger: Optional[Any] = None,
58
+ telemetry: Optional[TelemetryBatcher] = None,
56
59
  on_telemetry: Optional[Callable[[str, dict], Any]] = None,
57
60
  ):
58
61
  self._sessions = sessions
59
62
  self._config = config
60
63
  self._logger = logger
61
- # Host sink for page-telemetry frames. The SDK relays and forgets them:
62
- # there is no Centive telemetry endpoint and nothing is stored here.
63
- # Called as on_telemetry(user_id, frame); may be sync or async. See
64
- # _handle_telemetry.
64
+ # Product-event sinks for page-telemetry frames. `telemetry` is the
65
+ # Centive batcher (owned by the client, forwards to POST /anam/events);
66
+ # `on_telemetry(user_id, frame)` is an optional host callback, sync or
67
+ # async, that receives the validated frame as well. See _handle_telemetry.
68
+ self._telemetry = telemetry
69
+ self._telemetry_rate = ConnectionRateLimiter(
70
+ config.telemetry_rate_per_connection_per_minute
71
+ )
65
72
  self._on_telemetry = on_telemetry
66
73
  self._server: Optional[websockets.WebSocketServer] = None
67
74
  self._clients: Set[WebSocketServerProtocol] = set()
@@ -585,6 +592,7 @@ class WebSocketServer:
585
592
  # immediately after a drop).
586
593
  self._clients.discard(websocket)
587
594
  self._connection_users.pop(websocket, None)
595
+ self._telemetry_rate.forget(websocket)
588
596
  session_keys = self._connection_sessions.pop(websocket, set())
589
597
 
590
598
  safe_log(
@@ -953,9 +961,12 @@ class WebSocketServer:
953
961
  ) -> None:
954
962
  """handles a page-telemetry frame from the FE SDK.
955
963
 
956
- The SDK is a relay here, not a store: a validated frame goes to the
957
- host's on_telemetry callback and is forgotten. There is no Centive
958
- telemetry endpoint, and nothing is buffered.
964
+ A validated frame feeds up to two sinks: the Centive batcher (which
965
+ forwards the events to POST /anam/events in batches) and, when set,
966
+ the host's on_telemetry callback, which receives the whole frame.
967
+ When the batcher is active its enqueue result is what the ack reports;
968
+ a callback failure is then logged but does not change the ack, because
969
+ the events were still accepted for Centive.
959
970
 
960
971
  Telemetry frames carry a browser_session_id (a client tab, rolled over
961
972
  on idle) rather than the chat session_id, so identity is enforced with
@@ -1001,6 +1012,12 @@ class WebSocketServer:
1001
1012
  # still dropped in full, this 0 is just an uncounted one, not a bug.
1002
1013
  event_count = len(events) if isinstance(events, list) else 0
1003
1014
 
1015
+ # Flow control before validation: a chatty producer gets a reasoned
1016
+ # ack it understands, and the socket is never closed for it.
1017
+ if not self._telemetry_rate.allow(websocket):
1018
+ await ack(0, event_count, "rate_limited")
1019
+ return
1020
+
1004
1021
  failure = self._validate_telemetry(data, raw_message)
1005
1022
  if failure is not None:
1006
1023
  kind, code = failure
@@ -1016,11 +1033,31 @@ class WebSocketServer:
1016
1033
  }))
1017
1034
  return
1018
1035
 
1019
- if self._on_telemetry is None:
1036
+ batcher_active = self._telemetry is not None and self._config.telemetry_enabled
1037
+ enqueued = 0
1038
+ queue_dropped = 0
1039
+ if batcher_active:
1040
+ # The connection-bound user is authoritative; the frame's own
1041
+ # user_id was already proven absent or equal.
1042
+ parsed = []
1043
+ for event in data["events"]:
1044
+ try:
1045
+ parsed.append(TelemetryEvent.model_validate(event))
1046
+ except Exception:
1047
+ queue_dropped += 1 # _validate_telemetry makes this rare
1048
+ enqueued, dropped = self._telemetry.enqueue(user_id, data.get("sdk"), parsed)
1049
+ queue_dropped += dropped
1050
+
1051
+ if self._on_telemetry is None and not batcher_active:
1020
1052
  # Acked rather than errored: the frame was fine, this deployment
1021
1053
  # simply has nowhere to put it. The producer does not retry on a
1022
1054
  # non-zero `dropped`, which is what we want.
1023
- await ack(0, event_count, "no_telemetry_handler")
1055
+ reason = "telemetry_disabled" if not self._config.telemetry_enabled else "no_telemetry_handler"
1056
+ await ack(0, event_count, reason)
1057
+ return
1058
+
1059
+ if self._on_telemetry is None:
1060
+ await ack(enqueued, event_count - enqueued, "queue_full" if queue_dropped else None)
1024
1061
  return
1025
1062
 
1026
1063
  try:
@@ -1049,7 +1086,12 @@ class WebSocketServer:
1049
1086
  "on_telemetry callback failed, dropping frame",
1050
1087
  {"client": client_info, "user_id": user_id, "error": type(e).__name__},
1051
1088
  )
1052
- await ack(0, event_count, "handler_error")
1089
+ if batcher_active:
1090
+ # The events were still accepted for Centive; the callback
1091
+ # failure is the host's problem, not the producer's.
1092
+ await ack(enqueued, event_count - enqueued, "queue_full" if queue_dropped else None)
1093
+ else:
1094
+ await ack(0, event_count, "handler_error")
1053
1095
  return
1054
1096
 
1055
1097
  # Counts only: page paths, titles and properties are navigation data
@@ -1061,7 +1103,10 @@ class WebSocketServer:
1061
1103
  {"client": client_info, "user_id": user_id, "event_count": event_count},
1062
1104
  )
1063
1105
 
1064
- await ack(event_count, 0)
1106
+ if batcher_active:
1107
+ await ack(enqueued, event_count - enqueued, "queue_full" if queue_dropped else None)
1108
+ else:
1109
+ await ack(event_count, 0)
1065
1110
 
1066
1111
  async def _save_disconnected_sessions(
1067
1112
  self,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: centive-sdk
3
- Version: 2.1.0.dev3
3
+ Version: 2.2.0.dev4
4
4
  Summary: Python SDK for Centive backend integration
5
5
  Project-URL: Homepage, https://github.com/TheAgenticAI/centive-backend-sdk
6
6
  Project-URL: Repository, https://github.com/TheAgenticAI/centive-backend-sdk
@@ -195,6 +195,11 @@ an expired connection token.
195
195
  | `ws_host` | `str` | `0.0.0.0` | websocket bind address (`127.0.0.1` behind a proxy) |
196
196
  | `ws_port` | `int` | `8765` | websocket server port |
197
197
  | `ws_auth_mode` | `"token" \| "open"` | `"token"` | connection identity binding (see Security) |
198
+ | `telemetry_enabled` | `bool` | `True` | accept `telemetry` frames and forward page events |
199
+ | `telemetry_flush_interval_seconds` | `float` | `2.0` | max time an event waits before being sent |
200
+ | `telemetry_flush_max_events` | `int` | `50` | send when this many events are queued |
201
+ | `telemetry_max_queue_events` | `int` | `5000` | bounded queue; oldest dropped when full |
202
+ | `telemetry_rate_per_connection_per_minute` | `int` | `120` | telemetry frames accepted per socket per minute |
198
203
  | `ws_token_ttl_seconds`| `float` | `3600.0` | how long a connection token stays valid |
199
204
  | `ws_allowed_origins` | `list[str]` | `None` | allow-list of Origin headers for WS handshakes |
200
205
  | `ws_handshake_secret` | `str` | `None` | static shared secret (legacy `"open"` mode only) |
@@ -518,6 +523,54 @@ client = AsyncCentiveClient(
518
523
 
519
524
  For details on WebSocket event formats, see `websocket_messages.md`.
520
525
 
526
+ ## Page Tracking (Product Events)
527
+
528
+ From v2.2.0 the websocket server also accepts `type: "telemetry"` frames from
529
+ the browser SDK. These carry page views and page leaves for the connected end
530
+ user. The SDK validates each frame, queues the events, and forwards them to
531
+ Centive in batches (every 2 seconds or 50 events, whichever comes first).
532
+
533
+ Nothing is required from your code beyond what you already do for the avatar:
534
+ `initialize_websocket()` starts the queue and `aclose()` flushes it.
535
+
536
+ ```python
537
+ # Optional: emit your own server-side events into the same pipe.
538
+ client.telemetry.track(user_id, "invoice_paid", {"amount": 120})
539
+
540
+ # Optional: inspect the queue.
541
+ client.telemetry.stats()
542
+ # {'enqueued': 412, 'sent': 410, 'accepted': 398, 'rejected': 12,
543
+ # 'rejections_by_reason': {'user_not_mapped_to_account': 12}, 'queue_depth': 2, ...}
544
+ ```
545
+
546
+ Context back to Aria: when a user has a live Aria session, Centive answers the
547
+ events call with short context lines ("The user just opened the Invoices
548
+ page."). The SDK pushes each one to that user's browser as a `context` frame
549
+ and the browser SDK hands it to the avatar with `anamClient.addContext`. This
550
+ is why page tracking exists: Aria knows where the user is while they talk.
551
+
552
+ What Centive keeps: only events for end users that `tool_mapping` linked to an
553
+ account in your organization. Events for other users come back in the `202`
554
+ response as `rejected` with reason `user_not_mapped_to_account`; they are
555
+ counted in `stats()` and never retried. If that number is high, the
556
+ `company_name` you pass to `tool_mapping` does not match the account names in
557
+ Centive.
558
+
559
+ Safety properties:
560
+
561
+ - The browser cannot spoof identity: the frame's `user_id` must match the user
562
+ bound to the socket, exactly like transcript frames.
563
+ - Frames over 100 events or 64 KB, and more than 120 frames per minute per
564
+ connection, are dropped with a reasoned ack. The socket is never closed for
565
+ a telemetry violation.
566
+ - The queue is bounded (`telemetry_max_queue_events`, default 5000). When full,
567
+ the oldest events are dropped and counted. The websocket handler never blocks.
568
+ - Telemetry has its own circuit breaker; a failing events endpoint cannot stop
569
+ transcript saves.
570
+ - Set `telemetry_enabled=False` to turn the feature off entirely.
571
+
572
+ Wire contract: `docs/product-events/CONTRACTS.md` in the CentiveAI repository.
573
+
521
574
  ## Page Telemetry
522
575
 
523
576
  The FE SDK (`@centive/aria-sdk` 1.1.0+) sends page-view and page-leave events over
@@ -0,0 +1,23 @@
1
+ centive_sdk/__init__.py,sha256=JK6YZh1ZNQC1Wxoxot1cDZtP_Ulb20HmIB5Qnz7hiFc,2145
2
+ centive_sdk/_http.py,sha256=hJlHSov6Wvj2ok3R_EczTS3KgIIUv7QE3Kj9yHIfyyc,1056
3
+ centive_sdk/_logging.py,sha256=z8Kdi4vJdko0nkMeySGSHwPkEB2E7ucklc9FeJCbyOw,3518
4
+ centive_sdk/_retry.py,sha256=Q1cIFZyimj0RS4x14FEOseqpORCSydvJyzGiIPfDn7E,1755
5
+ centive_sdk/async_client.py,sha256=RRoCJrvdeLwQz5UJv2EjmlgTq7vntSV9ELMWHyNwJ5I,9355
6
+ centive_sdk/client.py,sha256=Evtsng8cqqRSoV54MI2IKbEW1q0vPOkxlz9zM0evH7k,3249
7
+ centive_sdk/config.py,sha256=HX0N7UCu9QLS5bICW_iME1LeBBG83Za2-cYoV-9VgLA,6187
8
+ centive_sdk/exceptions.py,sha256=wFy-hU0TJ0dHa6TEDI7FIQu9Lbg5aMeWxW9E-IMMUbI,1841
9
+ centive_sdk/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
10
+ centive_sdk/models/__init__.py,sha256=-ledNpbOi1Q14ELhvlDbGPF37N3yv5XJ-QsH2I6UMVQ,598
11
+ centive_sdk/models/requests.py,sha256=6KW5KEjyMAy-DAppTssu5b-LSf1doKtMnOfng5O3tNc,7153
12
+ centive_sdk/models/responses.py,sha256=zCYPB0aL2DXn-z0Febkuxo3DdZ_5sdpdGy2Jtf8Ubs8,2519
13
+ centive_sdk/resources/__init__.py,sha256=GLutz38X4F4FwPfSZy35EkWTV7l7t9RgcBKwXmz2X_Y,179
14
+ centive_sdk/resources/async_sessions.py,sha256=P7BDnkTKKU26kSCK5p91R593FurOHCHH0iF8PcTU1ZQ,20691
15
+ centive_sdk/resources/async_telemetry.py,sha256=wT12g9rG2dp_ml5YpAYCxobLlhfcsyy-LHty4bwwFZI,2431
16
+ centive_sdk/resources/message_accumulator.py,sha256=Or3sMGkXG9upeQ9sm8YWL_V5vvsxtnE9tu52VBUUGgY,19261
17
+ centive_sdk/resources/sessions.py,sha256=Ipg4bpmpejA5WoALbT49ah0c3I7x4OgPn9tSmfW8JAw,16741
18
+ centive_sdk/resources/telemetry_batcher.py,sha256=Oiysy_TsvxZU26H0vUjlWB5sH8M5krSuL9RjZgwDQiw,13537
19
+ centive_sdk/resources/websocket_server.py,sha256=iGS48oB4myV-sI0nArlBE3Y0fe_GlfBClFtVAPzrvdU,59760
20
+ centive_sdk-2.2.0.dev4.dist-info/METADATA,sha256=TAMEhgIrXDXIyl_E-T8tCqJNEZS9ZumpFUpPYTKgZic,36427
21
+ centive_sdk-2.2.0.dev4.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
22
+ centive_sdk-2.2.0.dev4.dist-info/licenses/LICENSE,sha256=9dFibPr7z9srhWwd35lQL2MX1Kx2qivEZdVQb9L8Q-Q,1064
23
+ centive_sdk-2.2.0.dev4.dist-info/RECORD,,
@@ -1,21 +0,0 @@
1
- centive_sdk/__init__.py,sha256=k7XAv5O8EVnFfe7VAFGmBeQRcrstn030LcKEW2wWllY,1801
2
- centive_sdk/_http.py,sha256=hJlHSov6Wvj2ok3R_EczTS3KgIIUv7QE3Kj9yHIfyyc,1056
3
- centive_sdk/_logging.py,sha256=GRNnqzNY_9SFP2mk3wSIfoyHwVZfNAHY3uTZ9vv1ihQ,3248
4
- centive_sdk/_retry.py,sha256=Q1cIFZyimj0RS4x14FEOseqpORCSydvJyzGiIPfDn7E,1755
5
- centive_sdk/async_client.py,sha256=NF8FGac3vXHqaqWyH5XQuznn_uGO-GDgcY-R_p8LL3Q,7776
6
- centive_sdk/client.py,sha256=Evtsng8cqqRSoV54MI2IKbEW1q0vPOkxlz9zM0evH7k,3249
7
- centive_sdk/config.py,sha256=uUDpYOeZYBAf24HmyNZiTqj_nQdbaFKQ2BLqwtFMQjQ,5379
8
- centive_sdk/exceptions.py,sha256=wFy-hU0TJ0dHa6TEDI7FIQu9Lbg5aMeWxW9E-IMMUbI,1841
9
- centive_sdk/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
10
- centive_sdk/models/__init__.py,sha256=-O7bQ0j1-QabhMqvI4AK8Aj5RHLFUbilKY08zJlnxYM,306
11
- centive_sdk/models/requests.py,sha256=XD6b6Dh1mSvDK-J5Q5JEOUCIQ7ozaaNK7NAYhC_Vt78,4195
12
- centive_sdk/models/responses.py,sha256=kPph7xXh2pVbY9EAGSdFXss-51Z0CwpaUisDFtnXUQU,2035
13
- centive_sdk/resources/__init__.py,sha256=GLutz38X4F4FwPfSZy35EkWTV7l7t9RgcBKwXmz2X_Y,179
14
- centive_sdk/resources/async_sessions.py,sha256=bFQSkRSGwB4yd1O-fUaTuEFdL3MLz6Z3G-HmtAZQgGw,19145
15
- centive_sdk/resources/message_accumulator.py,sha256=Or3sMGkXG9upeQ9sm8YWL_V5vvsxtnE9tu52VBUUGgY,19261
16
- centive_sdk/resources/sessions.py,sha256=Ipg4bpmpejA5WoALbT49ah0c3I7x4OgPn9tSmfW8JAw,16741
17
- centive_sdk/resources/websocket_server.py,sha256=uR8-rVD_nwe2-mLGvp_R6_oUltDhnUcCDGnBJLz_iDs,57440
18
- centive_sdk-2.1.0.dev3.dist-info/METADATA,sha256=O7o_1odgUD5xaXGjDlvmntKSYesessg5AS9lEqQX92s,33454
19
- centive_sdk-2.1.0.dev3.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
20
- centive_sdk-2.1.0.dev3.dist-info/licenses/LICENSE,sha256=9dFibPr7z9srhWwd35lQL2MX1Kx2qivEZdVQb9L8Q-Q,1064
21
- centive_sdk-2.1.0.dev3.dist-info/RECORD,,