centive-sdk 2.1.0.dev2__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
 
@@ -40,6 +42,13 @@ class AsyncCentiveClient:
40
42
 
41
43
  api_key and base_url fall back to the CENTIVE_API_KEY and CENTIVE_BASE_URL
42
44
  environment variables when not passed explicitly.
45
+
46
+ Pass on_telemetry=<callable> to receive page-telemetry frames from the FE
47
+ SDK: on_telemetry(user_id: str, frame: dict), sync or async, with the
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.
43
52
  """
44
53
  if api_key is not None:
45
54
  options["api_key"] = api_key
@@ -48,7 +57,14 @@ class AsyncCentiveClient:
48
57
  self.config = ClientConfig(**options)
49
58
  self._http_client = httpx.AsyncClient()
50
59
  self._logger = options.get("logger")
60
+ # Same pattern as logger: a callable, not config data, so it rides in
61
+ # **options and is read out here. ClientConfig ignores unknown keys.
62
+ self._on_telemetry = options.get("on_telemetry")
51
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)
52
68
  self._ws_server: Optional[WebSocketServer] = None
53
69
  self._last_pause_status: Optional[PauseStatusResponse] = None
54
70
  # One process serves many users; serialize server startup so concurrent
@@ -128,14 +144,38 @@ class AsyncCentiveClient:
128
144
  sessions=self.sessions,
129
145
  config=self.config,
130
146
  logger=self._logger,
147
+ telemetry=self._telemetry_batcher,
148
+ on_telemetry=self._on_telemetry,
131
149
  )
132
150
  # Publish only after a successful bind, so a failed start does
133
151
  # not leave a dead server in place and wedge every later call.
134
152
  await server.start(host=ws_host, port=ws_port, **ws_options)
135
153
  self._ws_server = server
154
+ self._telemetry_batcher.on_context = self._relay_live_context
155
+ self._telemetry_batcher.start()
136
156
 
137
157
  return self._ws_server.register_user(user_id)
138
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
+
139
179
  async def get_aria_status(self, user_id: str) -> PauseStatusResponse:
140
180
  """Checks if Aria is paused for a user (for UI display purposes).
141
181
 
@@ -170,6 +210,9 @@ class AsyncCentiveClient:
170
210
  await self._ws_server.stop()
171
211
  self._ws_server = None
172
212
 
213
+ # Flush queued page events before the HTTP client goes away.
214
+ await self._telemetry_batcher.stop()
215
+
173
216
  await self._http_client.aclose()
174
217
 
175
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
@@ -83,11 +84,34 @@ class ClientConfig(BaseModel):
83
84
  # Concurrent websocket connections accepted per user.
84
85
  max_connections_per_user: int = 5
85
86
 
87
+ # Page-telemetry frames (type="telemetry") from the FE SDK. These mirror the
88
+ # aria-sdk producer's own caps (MAX_EVENTS_PER_FRAME, MAX_FRAME_BYTES,
89
+ # MAX_PROPERTIES_BYTES). They bound what reaches the on_telemetry callback
90
+ # and keep the ack honest against the producer's own limits — the frame
91
+ # itself is still parsed up to the far looser ws_max_size (1 MiB) transport
92
+ # cap regardless of these.
93
+ max_telemetry_events_per_frame: int = 100
94
+ max_telemetry_frame_bytes: int = 65_536
95
+ max_telemetry_properties_bytes: int = 4_096
96
+
86
97
  # When the pause-status check fails (upstream 5xx, rate limit, network), the
87
98
  # SDK proceeds as active so a Centive outage does not hide every avatar. Set
88
99
  # False to fail closed and withhold connections instead.
89
100
  pause_check_fail_open: bool = True
90
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
+
91
115
  incremental_save_enabled: bool = Field(
92
116
  default=False,
93
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()