centive-sdk 2.2.0.dev4__tar.gz → 2.2.0.dev6__tar.gz
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-2.2.0.dev4 → centive_sdk-2.2.0.dev6}/CHANGELOG.md +14 -0
- {centive_sdk-2.2.0.dev4 → centive_sdk-2.2.0.dev6}/PKG-INFO +1 -1
- {centive_sdk-2.2.0.dev4 → centive_sdk-2.2.0.dev6}/pyproject.toml +1 -1
- {centive_sdk-2.2.0.dev4 → centive_sdk-2.2.0.dev6}/src/centive_sdk/models/requests.py +11 -0
- {centive_sdk-2.2.0.dev4 → centive_sdk-2.2.0.dev6}/src/centive_sdk/resources/async_sessions.py +13 -0
- {centive_sdk-2.2.0.dev4 → centive_sdk-2.2.0.dev6}/src/centive_sdk/resources/telemetry_batcher.py +26 -7
- {centive_sdk-2.2.0.dev4 → centive_sdk-2.2.0.dev6}/src/centive_sdk/resources/websocket_server.py +45 -2
- {centive_sdk-2.2.0.dev4 → centive_sdk-2.2.0.dev6}/tests/test_telemetry_batcher.py +53 -0
- {centive_sdk-2.2.0.dev4 → centive_sdk-2.2.0.dev6}/tests/test_websocket_server.py +84 -0
- {centive_sdk-2.2.0.dev4 → centive_sdk-2.2.0.dev6}/.gitignore +0 -0
- {centive_sdk-2.2.0.dev4 → centive_sdk-2.2.0.dev6}/LICENSE +0 -0
- {centive_sdk-2.2.0.dev4 → centive_sdk-2.2.0.dev6}/README.md +0 -0
- {centive_sdk-2.2.0.dev4 → centive_sdk-2.2.0.dev6}/blackbox/README.md +0 -0
- {centive_sdk-2.2.0.dev4 → centive_sdk-2.2.0.dev6}/blackbox/customer_app.py +0 -0
- {centive_sdk-2.2.0.dev4 → centive_sdk-2.2.0.dev6}/blackbox/harness.py +0 -0
- {centive_sdk-2.2.0.dev4 → centive_sdk-2.2.0.dev6}/blackbox/mock_centive_api.py +0 -0
- {centive_sdk-2.2.0.dev4 → centive_sdk-2.2.0.dev6}/blackbox/test_blackbox.py +0 -0
- {centive_sdk-2.2.0.dev4 → centive_sdk-2.2.0.dev6}/blackbox/test_blackbox_telemetry.py +0 -0
- {centive_sdk-2.2.0.dev4 → centive_sdk-2.2.0.dev6}/examples/async_example.py +0 -0
- {centive_sdk-2.2.0.dev4 → centive_sdk-2.2.0.dev6}/examples/fastapi_integration.py +0 -0
- {centive_sdk-2.2.0.dev4 → centive_sdk-2.2.0.dev6}/examples/sync_example.py +0 -0
- {centive_sdk-2.2.0.dev4 → centive_sdk-2.2.0.dev6}/examples/websocket_example.py +0 -0
- {centive_sdk-2.2.0.dev4 → centive_sdk-2.2.0.dev6}/src/centive_sdk/__init__.py +0 -0
- {centive_sdk-2.2.0.dev4 → centive_sdk-2.2.0.dev6}/src/centive_sdk/_http.py +0 -0
- {centive_sdk-2.2.0.dev4 → centive_sdk-2.2.0.dev6}/src/centive_sdk/_logging.py +0 -0
- {centive_sdk-2.2.0.dev4 → centive_sdk-2.2.0.dev6}/src/centive_sdk/_retry.py +0 -0
- {centive_sdk-2.2.0.dev4 → centive_sdk-2.2.0.dev6}/src/centive_sdk/async_client.py +0 -0
- {centive_sdk-2.2.0.dev4 → centive_sdk-2.2.0.dev6}/src/centive_sdk/client.py +0 -0
- {centive_sdk-2.2.0.dev4 → centive_sdk-2.2.0.dev6}/src/centive_sdk/config.py +0 -0
- {centive_sdk-2.2.0.dev4 → centive_sdk-2.2.0.dev6}/src/centive_sdk/exceptions.py +0 -0
- {centive_sdk-2.2.0.dev4 → centive_sdk-2.2.0.dev6}/src/centive_sdk/models/__init__.py +0 -0
- {centive_sdk-2.2.0.dev4 → centive_sdk-2.2.0.dev6}/src/centive_sdk/models/responses.py +0 -0
- {centive_sdk-2.2.0.dev4 → centive_sdk-2.2.0.dev6}/src/centive_sdk/py.typed +0 -0
- {centive_sdk-2.2.0.dev4 → centive_sdk-2.2.0.dev6}/src/centive_sdk/resources/__init__.py +0 -0
- {centive_sdk-2.2.0.dev4 → centive_sdk-2.2.0.dev6}/src/centive_sdk/resources/async_telemetry.py +0 -0
- {centive_sdk-2.2.0.dev4 → centive_sdk-2.2.0.dev6}/src/centive_sdk/resources/message_accumulator.py +0 -0
- {centive_sdk-2.2.0.dev4 → centive_sdk-2.2.0.dev6}/src/centive_sdk/resources/sessions.py +0 -0
- {centive_sdk-2.2.0.dev4 → centive_sdk-2.2.0.dev6}/tests/__init__.py +0 -0
- {centive_sdk-2.2.0.dev4 → centive_sdk-2.2.0.dev6}/tests/conftest.py +0 -0
- {centive_sdk-2.2.0.dev4 → centive_sdk-2.2.0.dev6}/tests/test_client_async.py +0 -0
- {centive_sdk-2.2.0.dev4 → centive_sdk-2.2.0.dev6}/tests/test_client_sync.py +0 -0
- {centive_sdk-2.2.0.dev4 → centive_sdk-2.2.0.dev6}/tests/test_config.py +0 -0
- {centive_sdk-2.2.0.dev4 → centive_sdk-2.2.0.dev6}/tests/test_error_mapping.py +0 -0
- {centive_sdk-2.2.0.dev4 → centive_sdk-2.2.0.dev6}/tests/test_logging_redaction.py +0 -0
- {centive_sdk-2.2.0.dev4 → centive_sdk-2.2.0.dev6}/tests/test_message_accumulator.py +0 -0
- {centive_sdk-2.2.0.dev4 → centive_sdk-2.2.0.dev6}/tests/test_retry_logic.py +0 -0
- {centive_sdk-2.2.0.dev4 → centive_sdk-2.2.0.dev6}/tests/test_websocket_security.py +0 -0
- {centive_sdk-2.2.0.dev4 → centive_sdk-2.2.0.dev6}/tests/test_websocket_telemetry.py +0 -0
|
@@ -25,6 +25,12 @@ Centive batcher and the host callback).
|
|
|
25
25
|
`stats()` as `context_received` / `context_delivered`. Anam has no
|
|
26
26
|
server-side way to inform a running session, so this is the only path.
|
|
27
27
|
- `AsyncSessions.send_events(ProductEventBatchRequest)`.
|
|
28
|
+
- **The session request carries the current page.** `TriggerSessionRequest.current_page`
|
|
29
|
+
(a `TelemetryPage`). The websocket server fills it from the `page` the browser
|
|
30
|
+
SDK puts in its `user_trigger` frame, or, for older browser SDKs, from the last
|
|
31
|
+
`page_view` seen on that connection. Centive builds the opening prompt from it:
|
|
32
|
+
measured on dev, trigger-session landed 1.3 s before the batched page_view, so
|
|
33
|
+
the assistant used to open on the page the previous visit had ended on.
|
|
28
34
|
- Models: `TelemetryEvent`, `TelemetryFrame`, `TelemetryUserBatch`,
|
|
29
35
|
`ProductEventBatchRequest`, `ProductEventIngestResponse`.
|
|
30
36
|
- Config: `events_path`, `telemetry_enabled`, `telemetry_flush_max_events`,
|
|
@@ -46,6 +52,14 @@ Centive batcher and the host callback).
|
|
|
46
52
|
`ServerError`) for every request, since retrying the same body cannot help.
|
|
47
53
|
- Log redaction now masks `page_path`, `page_title`, `path`, `title` and
|
|
48
54
|
`referrer_path`.
|
|
55
|
+
- **A 404 is no longer retried.** It is not a transient failure: the route is
|
|
56
|
+
absent on that deployment. This is the shape of a Centive backend older than
|
|
57
|
+
the SDK — `/anam/events` does not exist before page tracking — and of a
|
|
58
|
+
`trigger-session` for a user with no session in the organization. Previously
|
|
59
|
+
both were retried three times and then opened a circuit breaker to reach the
|
|
60
|
+
same answer. Telemetry now drops the batch, says once that the deployment has
|
|
61
|
+
no product-events endpoint, and keeps everything else working; a rolling
|
|
62
|
+
upgrade that brings the endpoint up is picked up by the next batch.
|
|
49
63
|
|
|
50
64
|
## v2.1.0
|
|
51
65
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: centive-sdk
|
|
3
|
-
Version: 2.2.0.
|
|
3
|
+
Version: 2.2.0.dev6
|
|
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
|
|
@@ -20,6 +20,14 @@ class TriggerSessionRequest(BaseModel):
|
|
|
20
20
|
user_trigger: Optional[bool] = Field(
|
|
21
21
|
None, description="optional trigger to force session creation"
|
|
22
22
|
)
|
|
23
|
+
# The page the browser has open at the moment of the request. Centive builds
|
|
24
|
+
# the opening prompt from it instead of from stored page views, which travel
|
|
25
|
+
# through the telemetry batcher and were measured arriving 1.3 s after this
|
|
26
|
+
# call. Optional: an older browser SDK sends none, and the websocket server
|
|
27
|
+
# then falls back to the last page_view it saw on the connection.
|
|
28
|
+
current_page: Optional["TelemetryPage"] = Field(
|
|
29
|
+
None, description="page the browser reports having open when it asks for a session"
|
|
30
|
+
)
|
|
23
31
|
|
|
24
32
|
|
|
25
33
|
class SessionMessage(BaseModel):
|
|
@@ -107,6 +115,9 @@ class TelemetryPage(BaseModel):
|
|
|
107
115
|
referrer_path: Optional[str] = Field(default=None, max_length=TELEMETRY_MAX_PATH_CHARS)
|
|
108
116
|
|
|
109
117
|
|
|
118
|
+
TriggerSessionRequest.model_rebuild()
|
|
119
|
+
|
|
120
|
+
|
|
110
121
|
class TelemetryEvent(BaseModel):
|
|
111
122
|
"""one C1 event as produced by the browser SDK; forwarded to Centive unchanged."""
|
|
112
123
|
|
{centive_sdk-2.2.0.dev4 → centive_sdk-2.2.0.dev6}/src/centive_sdk/resources/async_sessions.py
RENAMED
|
@@ -416,6 +416,19 @@ class AsyncSessions:
|
|
|
416
416
|
error_detail = self._extract_error_message(response)
|
|
417
417
|
raise AuthError(f"Authentication failed: {error_detail}", request_id=request_id)
|
|
418
418
|
|
|
419
|
+
elif response.status_code == 404:
|
|
420
|
+
# Not transient, so it must not be retried: the route is
|
|
421
|
+
# absent on this deployment. That is the shape of a Centive
|
|
422
|
+
# backend older than the SDK — /anam/events does not exist
|
|
423
|
+
# before page tracking — and of a trigger-session for a user
|
|
424
|
+
# with no session in this org. Retrying either one three
|
|
425
|
+
# times, then opening a circuit breaker, spends requests and
|
|
426
|
+
# fills logs to reach the same answer.
|
|
427
|
+
error_detail = self._extract_error_message(response)
|
|
428
|
+
raise ValidationError(
|
|
429
|
+
f"Not found (404): {error_detail}", request_id=request_id
|
|
430
|
+
)
|
|
431
|
+
|
|
419
432
|
elif response.status_code in [400, 413, 422]:
|
|
420
433
|
# 413 is "payload over the API's size caps": retrying the
|
|
421
434
|
# same body cannot succeed, so it is a validation failure.
|
{centive_sdk-2.2.0.dev4 → centive_sdk-2.2.0.dev6}/src/centive_sdk/resources/telemetry_batcher.py
RENAMED
|
@@ -95,6 +95,7 @@ class TelemetryBatcher:
|
|
|
95
95
|
self._wake = asyncio.Event()
|
|
96
96
|
self._running = False
|
|
97
97
|
self._auth_error_logged = False
|
|
98
|
+
self._missing_endpoint_logged = False
|
|
98
99
|
self.on_context: Optional[ContextCallable] = None
|
|
99
100
|
|
|
100
101
|
# ------------------------------------------------------------------ state
|
|
@@ -246,15 +247,33 @@ class TelemetryBatcher:
|
|
|
246
247
|
safe_log(self._logger, "error", f"telemetry rejected: invalid API key ({e})")
|
|
247
248
|
return False
|
|
248
249
|
except ValidationError as e:
|
|
249
|
-
# 400 / 413 / 422:
|
|
250
|
+
# 400 / 404 / 413 / 422: the payload or the route was wrong, and
|
|
251
|
+
# retrying cannot change either.
|
|
250
252
|
self._stats.dropped_non_retryable += len(batch)
|
|
251
253
|
self._stats.last_error = "ValidationError"
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
254
|
+
if "(404)" in str(e):
|
|
255
|
+
# A Centive deployment older than this SDK: page tracking has
|
|
256
|
+
# no endpoint to reach there. Said once, plainly, because the
|
|
257
|
+
# fix is a backend upgrade and not something a retry reaches.
|
|
258
|
+
# Not fatal: everything else the SDK does still works, and a
|
|
259
|
+
# rolling upgrade can bring the endpoint up under us, so later
|
|
260
|
+
# batches keep trying quietly.
|
|
261
|
+
if not self._missing_endpoint_logged:
|
|
262
|
+
self._missing_endpoint_logged = True
|
|
263
|
+
safe_log(
|
|
264
|
+
self._logger,
|
|
265
|
+
"warning",
|
|
266
|
+
"this Centive deployment has no product-events endpoint; page "
|
|
267
|
+
"events are being discarded. Everything else is unaffected. "
|
|
268
|
+
"Set telemetry_enabled=False to stop sending them.",
|
|
269
|
+
)
|
|
270
|
+
else:
|
|
271
|
+
safe_log(
|
|
272
|
+
self._logger,
|
|
273
|
+
"warning",
|
|
274
|
+
f"telemetry batch rejected by API, dropped: {e}",
|
|
275
|
+
{"events": len(batch)},
|
|
276
|
+
)
|
|
258
277
|
return True
|
|
259
278
|
except Exception as e: # unknown: treat as retryable but bounded by the breaker
|
|
260
279
|
self._breaker.record_failure()
|
{centive_sdk-2.2.0.dev4 → centive_sdk-2.2.0.dev6}/src/centive_sdk/resources/websocket_server.py
RENAMED
|
@@ -17,6 +17,7 @@ from ..models.requests import (
|
|
|
17
17
|
SessionMessage,
|
|
18
18
|
SessionMetadata,
|
|
19
19
|
TelemetryEvent,
|
|
20
|
+
TelemetryPage,
|
|
20
21
|
TriggerSessionRequest,
|
|
21
22
|
)
|
|
22
23
|
|
|
@@ -77,6 +78,10 @@ class WebSocketServer:
|
|
|
77
78
|
self._pending_users: Deque[str] = deque() # legacy "open" mode only
|
|
78
79
|
self._connection_tokens: Dict[str, Tuple[str, float]] = {} # token -> (user_id, expiry)
|
|
79
80
|
self._connection_users: Dict[WebSocketServerProtocol, str] = {}
|
|
81
|
+
# Last page_view seen on each connection, forwarded as trigger-session's
|
|
82
|
+
# current_page when the FE's user_trigger frame carries no page of its
|
|
83
|
+
# own (older browser SDKs). Bounded by the number of live connections.
|
|
84
|
+
self._connection_last_page: Dict[WebSocketServerProtocol, dict] = {}
|
|
80
85
|
# Every session key touched by a connection, so a disconnect saves all of
|
|
81
86
|
# them rather than only the most recent one.
|
|
82
87
|
self._connection_sessions: Dict[WebSocketServerProtocol, Set[str]] = {}
|
|
@@ -340,6 +345,7 @@ class WebSocketServer:
|
|
|
340
345
|
stale = [ws for ws in self._connection_users if not self._is_live(ws)]
|
|
341
346
|
for ws in stale:
|
|
342
347
|
self._connection_users.pop(ws, None)
|
|
348
|
+
self._connection_last_page.pop(ws, None)
|
|
343
349
|
self._clients.discard(ws)
|
|
344
350
|
|
|
345
351
|
return sum(1 for bound in self._connection_users.values() if bound == user_id)
|
|
@@ -592,6 +598,7 @@ class WebSocketServer:
|
|
|
592
598
|
# immediately after a drop).
|
|
593
599
|
self._clients.discard(websocket)
|
|
594
600
|
self._connection_users.pop(websocket, None)
|
|
601
|
+
self._connection_last_page.pop(websocket, None)
|
|
595
602
|
self._telemetry_rate.forget(websocket)
|
|
596
603
|
session_keys = self._connection_sessions.pop(websocket, set())
|
|
597
604
|
|
|
@@ -1033,6 +1040,8 @@ class WebSocketServer:
|
|
|
1033
1040
|
}))
|
|
1034
1041
|
return
|
|
1035
1042
|
|
|
1043
|
+
self._remember_page(websocket, data["events"])
|
|
1044
|
+
|
|
1036
1045
|
batcher_active = self._telemetry is not None and self._config.telemetry_enabled
|
|
1037
1046
|
enqueued = 0
|
|
1038
1047
|
queue_dropped = 0
|
|
@@ -1394,7 +1403,38 @@ class WebSocketServer:
|
|
|
1394
1403
|
return
|
|
1395
1404
|
|
|
1396
1405
|
user_trigger = data.get("user_trigger")
|
|
1397
|
-
|
|
1406
|
+
# The page the browser has open right now, so Centive's opening prompt
|
|
1407
|
+
# starts from it rather than from a page_view still waiting in the
|
|
1408
|
+
# batcher. The frame's own page wins; the last page_view seen on this
|
|
1409
|
+
# connection stands in for browser SDKs that predate the field.
|
|
1410
|
+
current_page = self._coerce_page(data.get("page")) or self._connection_last_page.get(websocket)
|
|
1411
|
+
await self._trigger_and_emit(
|
|
1412
|
+
websocket, user_id, user_trigger, client_info, current_page=current_page
|
|
1413
|
+
)
|
|
1414
|
+
|
|
1415
|
+
def _remember_page(self, websocket: WebSocketServerProtocol, events: Any) -> None:
|
|
1416
|
+
"""Keeps the newest page_view's page for this connection (see _handle_user_trigger)."""
|
|
1417
|
+
if not isinstance(events, list):
|
|
1418
|
+
return
|
|
1419
|
+
for event in reversed(events):
|
|
1420
|
+
if isinstance(event, dict) and event.get("event_type") == "page_view":
|
|
1421
|
+
page = self._coerce_page(event.get("page"))
|
|
1422
|
+
if page is not None:
|
|
1423
|
+
self._connection_last_page[websocket] = page
|
|
1424
|
+
return
|
|
1425
|
+
|
|
1426
|
+
@staticmethod
|
|
1427
|
+
def _coerce_page(value: Any) -> Optional[dict]:
|
|
1428
|
+
"""A C1 page object reduced to the fields Centive accepts, or None.
|
|
1429
|
+
|
|
1430
|
+
Never raises: a malformed hint must not cost the user their session.
|
|
1431
|
+
"""
|
|
1432
|
+
if not isinstance(value, dict):
|
|
1433
|
+
return None
|
|
1434
|
+
try:
|
|
1435
|
+
return TelemetryPage.model_validate(value).model_dump(exclude_none=True)
|
|
1436
|
+
except Exception:
|
|
1437
|
+
return None
|
|
1398
1438
|
|
|
1399
1439
|
async def _trigger_and_emit(
|
|
1400
1440
|
self,
|
|
@@ -1402,19 +1442,22 @@ class WebSocketServer:
|
|
|
1402
1442
|
user_id: str,
|
|
1403
1443
|
user_trigger: Optional[bool],
|
|
1404
1444
|
client_info: str,
|
|
1445
|
+
current_page: Optional[dict] = None,
|
|
1405
1446
|
) -> None:
|
|
1406
1447
|
"""calls trigger_session API and emits response to FE."""
|
|
1407
1448
|
safe_log(
|
|
1408
1449
|
self._logger,
|
|
1409
1450
|
"info",
|
|
1410
1451
|
"triggering session",
|
|
1411
|
-
{"client": client_info, "user_id": user_id, "user_trigger": user_trigger
|
|
1452
|
+
{"client": client_info, "user_id": user_id, "user_trigger": user_trigger,
|
|
1453
|
+
"has_page": current_page is not None},
|
|
1412
1454
|
)
|
|
1413
1455
|
|
|
1414
1456
|
try:
|
|
1415
1457
|
request = TriggerSessionRequest(
|
|
1416
1458
|
user_id=user_id,
|
|
1417
1459
|
user_trigger=user_trigger,
|
|
1460
|
+
current_page=current_page,
|
|
1418
1461
|
)
|
|
1419
1462
|
|
|
1420
1463
|
response = await self._sessions.trigger_session(request)
|
|
@@ -265,3 +265,56 @@ async def test_context_relay_failure_does_not_break_flush():
|
|
|
265
265
|
assert batcher.queue_depth == 0
|
|
266
266
|
assert batcher.stats()["accepted"] == 1
|
|
267
267
|
assert batcher.circuit_breaker.state == "closed"
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
@pytest.mark.asyncio
|
|
271
|
+
async def test_missing_endpoint_drops_without_retrying_or_tripping_the_breaker():
|
|
272
|
+
"""A Centive deployment older than this SDK has no /anam/events.
|
|
273
|
+
|
|
274
|
+
This is the rollout shape that matters: the SDK ships ahead of the backend
|
|
275
|
+
for a couple of weeks. A 404 must cost the page events and nothing else —
|
|
276
|
+
no retry storm, no circuit breaker, no effect on transcript saves.
|
|
277
|
+
"""
|
|
278
|
+
logged: list[str] = []
|
|
279
|
+
|
|
280
|
+
class Recorder:
|
|
281
|
+
def warning(self, message, *args, **kwargs):
|
|
282
|
+
logged.append(str(message))
|
|
283
|
+
|
|
284
|
+
def __getattr__(self, _name):
|
|
285
|
+
return lambda *a, **k: None
|
|
286
|
+
|
|
287
|
+
sender = RecordingSender([ValidationError("Not found (404): Not Found")])
|
|
288
|
+
batcher = TelemetryBatcher(sender, make_config(telemetry_flush_max_events=10), logger=Recorder())
|
|
289
|
+
batcher.enqueue("alice", None, [make_event(), make_event()])
|
|
290
|
+
|
|
291
|
+
await batcher.flush()
|
|
292
|
+
|
|
293
|
+
assert batcher.queue_depth == 0 # dropped, not requeued
|
|
294
|
+
assert batcher.circuit_breaker.state == "closed"
|
|
295
|
+
assert batcher.stats()["dropped_non_retryable"] == 2
|
|
296
|
+
assert len(sender.requests) == 1 # not retried
|
|
297
|
+
assert any("no product-events endpoint" in line for line in logged)
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
@pytest.mark.asyncio
|
|
301
|
+
async def test_the_missing_endpoint_is_reported_once_not_per_batch():
|
|
302
|
+
logged: list[str] = []
|
|
303
|
+
|
|
304
|
+
class Recorder:
|
|
305
|
+
def warning(self, message, *args, **kwargs):
|
|
306
|
+
logged.append(str(message))
|
|
307
|
+
|
|
308
|
+
def __getattr__(self, _name):
|
|
309
|
+
return lambda *a, **k: None
|
|
310
|
+
|
|
311
|
+
sender = RecordingSender(
|
|
312
|
+
[ValidationError("Not found (404): Not Found"), ValidationError("Not found (404): Not Found")]
|
|
313
|
+
)
|
|
314
|
+
batcher = TelemetryBatcher(sender, make_config(telemetry_flush_max_events=1), logger=Recorder())
|
|
315
|
+
batcher.enqueue("alice", None, [make_event(), make_event()])
|
|
316
|
+
|
|
317
|
+
await batcher.flush()
|
|
318
|
+
|
|
319
|
+
assert len(sender.requests) == 2
|
|
320
|
+
assert sum("no product-events endpoint" in line for line in logged) == 1
|
|
@@ -109,6 +109,90 @@ async def test_user_trigger_message(base_url, api_key, ws_port, trigger_session_
|
|
|
109
109
|
await client.aclose()
|
|
110
110
|
|
|
111
111
|
|
|
112
|
+
@pytest.mark.asyncio
|
|
113
|
+
@respx.mock
|
|
114
|
+
async def test_user_trigger_forwards_the_browsers_page(base_url, api_key, ws_port, trigger_session_response, pause_status_not_paused_response):
|
|
115
|
+
"""The page in the FE's user_trigger frame reaches trigger-session as current_page.
|
|
116
|
+
|
|
117
|
+
Measured on dev: trigger-session landed 1.3 s before the batched page_view,
|
|
118
|
+
so Centive built the opening prompt from the previous visit's last page.
|
|
119
|
+
"""
|
|
120
|
+
respx.get(f"{base_url}/anam/pause-status/user_123").mock(
|
|
121
|
+
return_value=httpx.Response(200, json=pause_status_not_paused_response)
|
|
122
|
+
)
|
|
123
|
+
route = respx.post(f"{base_url}/anam/trigger-session").mock(
|
|
124
|
+
return_value=httpx.Response(200, json=trigger_session_response)
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
client = AsyncCentiveClient(api_key=api_key, base_url=base_url)
|
|
128
|
+
token = await client.initialize_websocket(user_id="user_123", port=ws_port)
|
|
129
|
+
|
|
130
|
+
async with websockets.connect(ws_url(ws_port, token)) as ws:
|
|
131
|
+
await ws.recv() # auto-trigger on connect: no page known yet
|
|
132
|
+
assert "current_page" not in json.loads(route.calls.last.request.content)
|
|
133
|
+
|
|
134
|
+
await ws.send(json.dumps({
|
|
135
|
+
"user_trigger": True,
|
|
136
|
+
"page": {"path": "/billing/42", "pattern": "/billing/:id", "title": "Invoices",
|
|
137
|
+
"referrer_path": "/home", "junk": "ignored"},
|
|
138
|
+
}))
|
|
139
|
+
data = json.loads(await asyncio.wait_for(ws.recv(), timeout=5.0))
|
|
140
|
+
assert data["status"] == "success"
|
|
141
|
+
|
|
142
|
+
body = json.loads(route.calls.last.request.content)
|
|
143
|
+
assert body["external_user_id"] == "user_123"
|
|
144
|
+
assert body["current_page"] == {
|
|
145
|
+
"path": "/billing/42", "pattern": "/billing/:id", "title": "Invoices", "referrer_path": "/home",
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
# A malformed hint costs nothing: the session is still triggered, without it.
|
|
149
|
+
await ws.send(json.dumps({"user_trigger": True, "page": {"pattern": 7}}))
|
|
150
|
+
data = json.loads(await asyncio.wait_for(ws.recv(), timeout=5.0))
|
|
151
|
+
assert data["status"] == "success"
|
|
152
|
+
assert "current_page" not in json.loads(route.calls.last.request.content)
|
|
153
|
+
|
|
154
|
+
await client.aclose()
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
@pytest.mark.asyncio
|
|
158
|
+
@respx.mock
|
|
159
|
+
async def test_user_trigger_falls_back_to_the_last_page_view_seen(
|
|
160
|
+
base_url, api_key, ws_port, trigger_session_response, pause_status_not_paused_response, telemetry_event_data
|
|
161
|
+
):
|
|
162
|
+
"""A browser SDK that predates the `page` field still gets the right opening
|
|
163
|
+
page: the last page_view on the connection stands in."""
|
|
164
|
+
respx.get(f"{base_url}/anam/pause-status/user_123").mock(
|
|
165
|
+
return_value=httpx.Response(200, json=pause_status_not_paused_response)
|
|
166
|
+
)
|
|
167
|
+
route = respx.post(f"{base_url}/anam/trigger-session").mock(
|
|
168
|
+
return_value=httpx.Response(200, json=trigger_session_response)
|
|
169
|
+
)
|
|
170
|
+
respx.post(f"{base_url}/anam/events").mock(
|
|
171
|
+
return_value=httpx.Response(202, json={"accepted": 2, "rejected": 0, "rejections": [], "context": []})
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
client = AsyncCentiveClient(api_key=api_key, base_url=base_url)
|
|
175
|
+
token = await client.initialize_websocket(user_id="user_123", port=ws_port)
|
|
176
|
+
|
|
177
|
+
async with websockets.connect(ws_url(ws_port, token)) as ws:
|
|
178
|
+
await ws.recv()
|
|
179
|
+
frame = dict(telemetry_event_data)
|
|
180
|
+
await ws.send(json.dumps(frame))
|
|
181
|
+
ack = json.loads(await asyncio.wait_for(ws.recv(), timeout=5.0))
|
|
182
|
+
assert ack["type"] == "telemetry_ack"
|
|
183
|
+
|
|
184
|
+
await ws.send(json.dumps({"user_trigger": True}))
|
|
185
|
+
data = json.loads(await asyncio.wait_for(ws.recv(), timeout=5.0))
|
|
186
|
+
assert data["status"] == "success"
|
|
187
|
+
|
|
188
|
+
body = json.loads(route.calls.last.request.content)
|
|
189
|
+
latest_view = [e for e in frame["events"] if e["event_type"] == "page_view"][-1]["page"]
|
|
190
|
+
assert body["current_page"]["path"] == latest_view["path"]
|
|
191
|
+
assert body["current_page"]["pattern"] == latest_view["pattern"]
|
|
192
|
+
|
|
193
|
+
await client.aclose()
|
|
194
|
+
|
|
195
|
+
|
|
112
196
|
@pytest.mark.asyncio
|
|
113
197
|
@respx.mock
|
|
114
198
|
async def test_connection_without_token_rejected(base_url, api_key, ws_port):
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{centive_sdk-2.2.0.dev4 → centive_sdk-2.2.0.dev6}/src/centive_sdk/resources/async_telemetry.py
RENAMED
|
File without changes
|
{centive_sdk-2.2.0.dev4 → centive_sdk-2.2.0.dev6}/src/centive_sdk/resources/message_accumulator.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|