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 +12 -0
- centive_sdk/_logging.py +5 -0
- centive_sdk/async_client.py +43 -0
- centive_sdk/config.py +24 -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 +270 -1
- {centive_sdk-2.1.0.dev2.dist-info → centive_sdk-2.2.0.dev4.dist-info}/METADATA +138 -1
- centive_sdk-2.2.0.dev4.dist-info/RECORD +23 -0
- centive_sdk-2.1.0.dev2.dist-info/RECORD +0 -21
- {centive_sdk-2.1.0.dev2.dist-info → centive_sdk-2.2.0.dev4.dist-info}/WHEEL +0 -0
- {centive_sdk-2.1.0.dev2.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
|
|
|
@@ -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)"
|
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()
|