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 +12 -0
- centive_sdk/_logging.py +5 -0
- centive_sdk/async_client.py +36 -2
- centive_sdk/config.py +14 -0
- centive_sdk/models/__init__.py +19 -2
- centive_sdk/models/requests.py +78 -2
- centive_sdk/models/responses.py +13 -0
- centive_sdk/resources/async_sessions.py +39 -2
- centive_sdk/resources/async_telemetry.py +65 -0
- centive_sdk/resources/telemetry_batcher.py +341 -0
- centive_sdk/resources/websocket_server.py +56 -11
- {centive_sdk-2.1.0.dev3.dist-info → centive_sdk-2.2.0.dev4.dist-info}/METADATA +54 -1
- centive_sdk-2.2.0.dev4.dist-info/RECORD +23 -0
- centive_sdk-2.1.0.dev3.dist-info/RECORD +0 -21
- {centive_sdk-2.1.0.dev3.dist-info → centive_sdk-2.2.0.dev4.dist-info}/WHEEL +0 -0
- {centive_sdk-2.1.0.dev3.dist-info → centive_sdk-2.2.0.dev4.dist-info}/licenses/LICENSE +0 -0
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]"
|
centive_sdk/async_client.py
CHANGED
|
@@ -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"].
|
|
47
|
-
|
|
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)"
|
centive_sdk/models/__init__.py
CHANGED
|
@@ -1,10 +1,27 @@
|
|
|
1
|
-
from .requests import
|
|
2
|
-
|
|
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
|
]
|
centive_sdk/models/requests.py
CHANGED
|
@@ -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)
|
centive_sdk/models/responses.py
CHANGED
|
@@ -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
|
|
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
|
-
#
|
|
62
|
-
#
|
|
63
|
-
#
|
|
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
|
-
|
|
957
|
-
|
|
958
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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,,
|
|
File without changes
|
|
File without changes
|