centive-sdk 2.2.0.dev5__tar.gz → 2.2.0.dev8__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.dev5 → centive_sdk-2.2.0.dev8}/CHANGELOG.md +15 -0
- {centive_sdk-2.2.0.dev5 → centive_sdk-2.2.0.dev8}/PKG-INFO +1 -1
- {centive_sdk-2.2.0.dev5 → centive_sdk-2.2.0.dev8}/pyproject.toml +1 -1
- {centive_sdk-2.2.0.dev5 → centive_sdk-2.2.0.dev8}/src/centive_sdk/config.py +6 -0
- {centive_sdk-2.2.0.dev5 → centive_sdk-2.2.0.dev8}/src/centive_sdk/models/requests.py +11 -0
- {centive_sdk-2.2.0.dev5 → centive_sdk-2.2.0.dev8}/src/centive_sdk/resources/websocket_server.py +147 -6
- {centive_sdk-2.2.0.dev5 → centive_sdk-2.2.0.dev8}/tests/test_websocket_server.py +189 -0
- {centive_sdk-2.2.0.dev5 → centive_sdk-2.2.0.dev8}/.gitignore +0 -0
- {centive_sdk-2.2.0.dev5 → centive_sdk-2.2.0.dev8}/LICENSE +0 -0
- {centive_sdk-2.2.0.dev5 → centive_sdk-2.2.0.dev8}/README.md +0 -0
- {centive_sdk-2.2.0.dev5 → centive_sdk-2.2.0.dev8}/blackbox/README.md +0 -0
- {centive_sdk-2.2.0.dev5 → centive_sdk-2.2.0.dev8}/blackbox/customer_app.py +0 -0
- {centive_sdk-2.2.0.dev5 → centive_sdk-2.2.0.dev8}/blackbox/harness.py +0 -0
- {centive_sdk-2.2.0.dev5 → centive_sdk-2.2.0.dev8}/blackbox/mock_centive_api.py +0 -0
- {centive_sdk-2.2.0.dev5 → centive_sdk-2.2.0.dev8}/blackbox/test_blackbox.py +0 -0
- {centive_sdk-2.2.0.dev5 → centive_sdk-2.2.0.dev8}/blackbox/test_blackbox_telemetry.py +0 -0
- {centive_sdk-2.2.0.dev5 → centive_sdk-2.2.0.dev8}/examples/async_example.py +0 -0
- {centive_sdk-2.2.0.dev5 → centive_sdk-2.2.0.dev8}/examples/fastapi_integration.py +0 -0
- {centive_sdk-2.2.0.dev5 → centive_sdk-2.2.0.dev8}/examples/sync_example.py +0 -0
- {centive_sdk-2.2.0.dev5 → centive_sdk-2.2.0.dev8}/examples/websocket_example.py +0 -0
- {centive_sdk-2.2.0.dev5 → centive_sdk-2.2.0.dev8}/src/centive_sdk/__init__.py +0 -0
- {centive_sdk-2.2.0.dev5 → centive_sdk-2.2.0.dev8}/src/centive_sdk/_http.py +0 -0
- {centive_sdk-2.2.0.dev5 → centive_sdk-2.2.0.dev8}/src/centive_sdk/_logging.py +0 -0
- {centive_sdk-2.2.0.dev5 → centive_sdk-2.2.0.dev8}/src/centive_sdk/_retry.py +0 -0
- {centive_sdk-2.2.0.dev5 → centive_sdk-2.2.0.dev8}/src/centive_sdk/async_client.py +0 -0
- {centive_sdk-2.2.0.dev5 → centive_sdk-2.2.0.dev8}/src/centive_sdk/client.py +0 -0
- {centive_sdk-2.2.0.dev5 → centive_sdk-2.2.0.dev8}/src/centive_sdk/exceptions.py +0 -0
- {centive_sdk-2.2.0.dev5 → centive_sdk-2.2.0.dev8}/src/centive_sdk/models/__init__.py +0 -0
- {centive_sdk-2.2.0.dev5 → centive_sdk-2.2.0.dev8}/src/centive_sdk/models/responses.py +0 -0
- {centive_sdk-2.2.0.dev5 → centive_sdk-2.2.0.dev8}/src/centive_sdk/py.typed +0 -0
- {centive_sdk-2.2.0.dev5 → centive_sdk-2.2.0.dev8}/src/centive_sdk/resources/__init__.py +0 -0
- {centive_sdk-2.2.0.dev5 → centive_sdk-2.2.0.dev8}/src/centive_sdk/resources/async_sessions.py +0 -0
- {centive_sdk-2.2.0.dev5 → centive_sdk-2.2.0.dev8}/src/centive_sdk/resources/async_telemetry.py +0 -0
- {centive_sdk-2.2.0.dev5 → centive_sdk-2.2.0.dev8}/src/centive_sdk/resources/message_accumulator.py +0 -0
- {centive_sdk-2.2.0.dev5 → centive_sdk-2.2.0.dev8}/src/centive_sdk/resources/sessions.py +0 -0
- {centive_sdk-2.2.0.dev5 → centive_sdk-2.2.0.dev8}/src/centive_sdk/resources/telemetry_batcher.py +0 -0
- {centive_sdk-2.2.0.dev5 → centive_sdk-2.2.0.dev8}/tests/__init__.py +0 -0
- {centive_sdk-2.2.0.dev5 → centive_sdk-2.2.0.dev8}/tests/conftest.py +0 -0
- {centive_sdk-2.2.0.dev5 → centive_sdk-2.2.0.dev8}/tests/test_client_async.py +0 -0
- {centive_sdk-2.2.0.dev5 → centive_sdk-2.2.0.dev8}/tests/test_client_sync.py +0 -0
- {centive_sdk-2.2.0.dev5 → centive_sdk-2.2.0.dev8}/tests/test_config.py +0 -0
- {centive_sdk-2.2.0.dev5 → centive_sdk-2.2.0.dev8}/tests/test_error_mapping.py +0 -0
- {centive_sdk-2.2.0.dev5 → centive_sdk-2.2.0.dev8}/tests/test_logging_redaction.py +0 -0
- {centive_sdk-2.2.0.dev5 → centive_sdk-2.2.0.dev8}/tests/test_message_accumulator.py +0 -0
- {centive_sdk-2.2.0.dev5 → centive_sdk-2.2.0.dev8}/tests/test_retry_logic.py +0 -0
- {centive_sdk-2.2.0.dev5 → centive_sdk-2.2.0.dev8}/tests/test_telemetry_batcher.py +0 -0
- {centive_sdk-2.2.0.dev5 → centive_sdk-2.2.0.dev8}/tests/test_websocket_security.py +0 -0
- {centive_sdk-2.2.0.dev5 → centive_sdk-2.2.0.dev8}/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`,
|
|
@@ -34,6 +40,15 @@ Centive batcher and the host callback).
|
|
|
34
40
|
|
|
35
41
|
### Behavior
|
|
36
42
|
|
|
43
|
+
- **One trigger-session call per connection start.** The connect-time
|
|
44
|
+
automatic trigger now waits `session_trigger_grace_seconds` (default 0.5)
|
|
45
|
+
and stands down when the browser SDK has asked for a session itself in the
|
|
46
|
+
meantime, which it does the moment its socket opens, with the page it has
|
|
47
|
+
open. Before, both reached Centive within milliseconds: two Anam tokens per
|
|
48
|
+
connection and an opening prompt built from the page-less request. A request
|
|
49
|
+
that lands while another trigger for the same connection is in flight waits
|
|
50
|
+
for it and receives the same frame. Set the grace to 0 for the old behaviour.
|
|
51
|
+
|
|
37
52
|
- The telemetry queue has its own circuit breaker, so a failing events
|
|
38
53
|
endpoint cannot stop transcript saves (and vice versa).
|
|
39
54
|
- The queue is bounded (`telemetry_max_queue_events`, default 5000); when full
|
|
@@ -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.dev8
|
|
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
|
|
@@ -83,6 +83,12 @@ class ClientConfig(BaseModel):
|
|
|
83
83
|
max_session_id_chars: int = 200
|
|
84
84
|
# Concurrent websocket connections accepted per user.
|
|
85
85
|
max_connections_per_user: int = 5
|
|
86
|
+
# How long the connect-time session trigger waits for the browser SDK to
|
|
87
|
+
# ask for a session itself before firing. Browser SDKs since 1.0 ask the
|
|
88
|
+
# moment the socket opens, and their request is the one that carries the
|
|
89
|
+
# page the user has open; firing first meant two Centive calls and two Anam
|
|
90
|
+
# tokens per connection. 0 restores the old immediate trigger.
|
|
91
|
+
session_trigger_grace_seconds: float = 0.5
|
|
86
92
|
|
|
87
93
|
# Page-telemetry frames (type="telemetry") from the FE SDK. These mirror the
|
|
88
94
|
# aria-sdk producer's own caps (MAX_EVENTS_PER_FRAME, MAX_FRAME_BYTES,
|
|
@@ -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.dev5 → centive_sdk-2.2.0.dev8}/src/centive_sdk/resources/websocket_server.py
RENAMED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import asyncio
|
|
1
2
|
import inspect
|
|
2
3
|
import json
|
|
3
4
|
import secrets
|
|
@@ -17,6 +18,7 @@ from ..models.requests import (
|
|
|
17
18
|
SessionMessage,
|
|
18
19
|
SessionMetadata,
|
|
19
20
|
TelemetryEvent,
|
|
21
|
+
TelemetryPage,
|
|
20
22
|
TriggerSessionRequest,
|
|
21
23
|
)
|
|
22
24
|
|
|
@@ -77,6 +79,18 @@ class WebSocketServer:
|
|
|
77
79
|
self._pending_users: Deque[str] = deque() # legacy "open" mode only
|
|
78
80
|
self._connection_tokens: Dict[str, Tuple[str, float]] = {} # token -> (user_id, expiry)
|
|
79
81
|
self._connection_users: Dict[WebSocketServerProtocol, str] = {}
|
|
82
|
+
# Last page_view seen on each connection, forwarded as trigger-session's
|
|
83
|
+
# current_page when the FE's user_trigger frame carries no page of its
|
|
84
|
+
# own (older browser SDKs). Bounded by the number of live connections.
|
|
85
|
+
self._connection_last_page: Dict[WebSocketServerProtocol, dict] = {}
|
|
86
|
+
# One Centive trigger-session call per connection start, not two: the
|
|
87
|
+
# connect-time automatic trigger is held back for a grace period and
|
|
88
|
+
# stands down when the client asked itself (_auto_trigger_after_grace);
|
|
89
|
+
# a request that lands while another trigger for the same connection is
|
|
90
|
+
# in flight waits for it and receives the same frame.
|
|
91
|
+
self._connection_trigger_tasks: Dict[WebSocketServerProtocol, asyncio.Task] = {}
|
|
92
|
+
self._connection_triggered: set = set()
|
|
93
|
+
self._connection_trigger_inflight: Dict[WebSocketServerProtocol, asyncio.Future] = {}
|
|
80
94
|
# Every session key touched by a connection, so a disconnect saves all of
|
|
81
95
|
# them rather than only the most recent one.
|
|
82
96
|
self._connection_sessions: Dict[WebSocketServerProtocol, Set[str]] = {}
|
|
@@ -340,6 +354,7 @@ class WebSocketServer:
|
|
|
340
354
|
stale = [ws for ws in self._connection_users if not self._is_live(ws)]
|
|
341
355
|
for ws in stale:
|
|
342
356
|
self._connection_users.pop(ws, None)
|
|
357
|
+
self._connection_last_page.pop(ws, None)
|
|
343
358
|
self._clients.discard(ws)
|
|
344
359
|
|
|
345
360
|
return sum(1 for bound in self._connection_users.values() if bound == user_id)
|
|
@@ -563,7 +578,9 @@ class WebSocketServer:
|
|
|
563
578
|
{"client": client_info, "user_id": user_id},
|
|
564
579
|
)
|
|
565
580
|
|
|
566
|
-
|
|
581
|
+
self._connection_trigger_tasks[websocket] = asyncio.create_task(
|
|
582
|
+
self._auto_trigger_after_grace(websocket, user_id, client_info)
|
|
583
|
+
)
|
|
567
584
|
|
|
568
585
|
try:
|
|
569
586
|
async for message in websocket:
|
|
@@ -592,6 +609,12 @@ class WebSocketServer:
|
|
|
592
609
|
# immediately after a drop).
|
|
593
610
|
self._clients.discard(websocket)
|
|
594
611
|
self._connection_users.pop(websocket, None)
|
|
612
|
+
self._connection_last_page.pop(websocket, None)
|
|
613
|
+
self._connection_triggered.discard(websocket)
|
|
614
|
+
self._connection_trigger_inflight.pop(websocket, None)
|
|
615
|
+
pending_trigger = self._connection_trigger_tasks.pop(websocket, None)
|
|
616
|
+
if pending_trigger is not None:
|
|
617
|
+
pending_trigger.cancel()
|
|
595
618
|
self._telemetry_rate.forget(websocket)
|
|
596
619
|
session_keys = self._connection_sessions.pop(websocket, set())
|
|
597
620
|
|
|
@@ -1033,6 +1056,8 @@ class WebSocketServer:
|
|
|
1033
1056
|
}))
|
|
1034
1057
|
return
|
|
1035
1058
|
|
|
1059
|
+
self._remember_page(websocket, data["events"])
|
|
1060
|
+
|
|
1036
1061
|
batcher_active = self._telemetry is not None and self._config.telemetry_enabled
|
|
1037
1062
|
enqueued = 0
|
|
1038
1063
|
queue_dropped = 0
|
|
@@ -1394,7 +1419,75 @@ class WebSocketServer:
|
|
|
1394
1419
|
return
|
|
1395
1420
|
|
|
1396
1421
|
user_trigger = data.get("user_trigger")
|
|
1397
|
-
|
|
1422
|
+
# The page the browser has open right now, so Centive's opening prompt
|
|
1423
|
+
# starts from it rather than from a page_view still waiting in the
|
|
1424
|
+
# batcher. The frame's own page wins; the last page_view seen on this
|
|
1425
|
+
# connection stands in for browser SDKs that predate the field.
|
|
1426
|
+
current_page = self._coerce_page(data.get("page")) or self._connection_last_page.get(websocket)
|
|
1427
|
+
# The client asked itself, so the connect-time automatic trigger, if it
|
|
1428
|
+
# is still waiting out its grace period, has nothing left to do.
|
|
1429
|
+
self._connection_triggered.add(websocket)
|
|
1430
|
+
await self._trigger_and_emit(
|
|
1431
|
+
websocket, user_id, user_trigger, client_info, current_page=current_page
|
|
1432
|
+
)
|
|
1433
|
+
|
|
1434
|
+
async def _auto_trigger_after_grace(
|
|
1435
|
+
self, websocket: WebSocketServerProtocol, user_id: str, client_info: str
|
|
1436
|
+
) -> None:
|
|
1437
|
+
"""The connect-time session trigger, held back briefly.
|
|
1438
|
+
|
|
1439
|
+
Browser SDKs since 1.0 ask for a token themselves the moment the socket
|
|
1440
|
+
opens, and that request is the one that carries the page the user has
|
|
1441
|
+
open. Firing the automatic trigger first (and before the message loop
|
|
1442
|
+
even started reading) meant two Centive calls and two Anam tokens per
|
|
1443
|
+
connection, with the opening prompt built from the page-less one. Now
|
|
1444
|
+
the automatic trigger waits config.session_trigger_grace_seconds and
|
|
1445
|
+
stands down when the client has asked in the meantime. A client that
|
|
1446
|
+
never asks (older SDKs, custom frontends) still gets its session, half
|
|
1447
|
+
a second later.
|
|
1448
|
+
"""
|
|
1449
|
+
try:
|
|
1450
|
+
grace = max(0.0, float(self._config.session_trigger_grace_seconds or 0))
|
|
1451
|
+
if grace:
|
|
1452
|
+
await asyncio.sleep(grace)
|
|
1453
|
+
if websocket in self._connection_triggered or not self._is_live(websocket):
|
|
1454
|
+
safe_log(
|
|
1455
|
+
self._logger,
|
|
1456
|
+
"info",
|
|
1457
|
+
"automatic session trigger stood down",
|
|
1458
|
+
{"client": client_info, "user_id": user_id,
|
|
1459
|
+
"reason": "client asked" if websocket in self._connection_triggered else "connection gone"},
|
|
1460
|
+
)
|
|
1461
|
+
return
|
|
1462
|
+
await self._trigger_and_emit(websocket, user_id, None, client_info)
|
|
1463
|
+
except asyncio.CancelledError:
|
|
1464
|
+
return
|
|
1465
|
+
finally:
|
|
1466
|
+
self._connection_trigger_tasks.pop(websocket, None)
|
|
1467
|
+
|
|
1468
|
+
def _remember_page(self, websocket: WebSocketServerProtocol, events: Any) -> None:
|
|
1469
|
+
"""Keeps the newest page_view's page for this connection (see _handle_user_trigger)."""
|
|
1470
|
+
if not isinstance(events, list):
|
|
1471
|
+
return
|
|
1472
|
+
for event in reversed(events):
|
|
1473
|
+
if isinstance(event, dict) and event.get("event_type") == "page_view":
|
|
1474
|
+
page = self._coerce_page(event.get("page"))
|
|
1475
|
+
if page is not None:
|
|
1476
|
+
self._connection_last_page[websocket] = page
|
|
1477
|
+
return
|
|
1478
|
+
|
|
1479
|
+
@staticmethod
|
|
1480
|
+
def _coerce_page(value: Any) -> Optional[dict]:
|
|
1481
|
+
"""A C1 page object reduced to the fields Centive accepts, or None.
|
|
1482
|
+
|
|
1483
|
+
Never raises: a malformed hint must not cost the user their session.
|
|
1484
|
+
"""
|
|
1485
|
+
if not isinstance(value, dict):
|
|
1486
|
+
return None
|
|
1487
|
+
try:
|
|
1488
|
+
return TelemetryPage.model_validate(value).model_dump(exclude_none=True)
|
|
1489
|
+
except Exception:
|
|
1490
|
+
return None
|
|
1398
1491
|
|
|
1399
1492
|
async def _trigger_and_emit(
|
|
1400
1493
|
self,
|
|
@@ -1402,19 +1495,65 @@ class WebSocketServer:
|
|
|
1402
1495
|
user_id: str,
|
|
1403
1496
|
user_trigger: Optional[bool],
|
|
1404
1497
|
client_info: str,
|
|
1498
|
+
current_page: Optional[dict] = None,
|
|
1405
1499
|
) -> None:
|
|
1406
|
-
"""calls trigger_session API and emits response to FE.
|
|
1500
|
+
"""calls trigger_session API and emits response to FE.
|
|
1501
|
+
|
|
1502
|
+
Two triggers for the same connection at once (the automatic one mid
|
|
1503
|
+
call when the client's own request lands) share one Centive call: the
|
|
1504
|
+
second waits for the first and re-emits its frame.
|
|
1505
|
+
"""
|
|
1506
|
+
inflight = self._connection_trigger_inflight.get(websocket)
|
|
1507
|
+
if inflight is not None:
|
|
1508
|
+
safe_log(
|
|
1509
|
+
self._logger,
|
|
1510
|
+
"info",
|
|
1511
|
+
"session trigger coalesced with one in flight",
|
|
1512
|
+
{"client": client_info, "user_id": user_id},
|
|
1513
|
+
)
|
|
1514
|
+
try:
|
|
1515
|
+
frame = await asyncio.shield(inflight)
|
|
1516
|
+
except Exception: # noqa: BLE001 - the first caller already reported it
|
|
1517
|
+
frame = None
|
|
1518
|
+
if frame is not None:
|
|
1519
|
+
try:
|
|
1520
|
+
await websocket.send(frame)
|
|
1521
|
+
except Exception: # noqa: BLE001
|
|
1522
|
+
pass
|
|
1523
|
+
return
|
|
1524
|
+
|
|
1525
|
+
done: asyncio.Future = asyncio.get_running_loop().create_future()
|
|
1526
|
+
self._connection_trigger_inflight[websocket] = done
|
|
1527
|
+
emitted: Optional[str] = None
|
|
1528
|
+
try:
|
|
1529
|
+
emitted = await self._trigger_once(websocket, user_id, user_trigger, client_info, current_page)
|
|
1530
|
+
finally:
|
|
1531
|
+
self._connection_trigger_inflight.pop(websocket, None)
|
|
1532
|
+
if not done.done():
|
|
1533
|
+
done.set_result(emitted)
|
|
1534
|
+
|
|
1535
|
+
async def _trigger_once(
|
|
1536
|
+
self,
|
|
1537
|
+
websocket: WebSocketServerProtocol,
|
|
1538
|
+
user_id: str,
|
|
1539
|
+
user_trigger: Optional[bool],
|
|
1540
|
+
client_info: str,
|
|
1541
|
+
current_page: Optional[dict],
|
|
1542
|
+
) -> Optional[str]:
|
|
1543
|
+
"""One trigger-session call. Returns the success frame it sent, if any."""
|
|
1407
1544
|
safe_log(
|
|
1408
1545
|
self._logger,
|
|
1409
1546
|
"info",
|
|
1410
1547
|
"triggering session",
|
|
1411
|
-
{"client": client_info, "user_id": user_id, "user_trigger": user_trigger
|
|
1548
|
+
{"client": client_info, "user_id": user_id, "user_trigger": user_trigger,
|
|
1549
|
+
"has_page": current_page is not None},
|
|
1412
1550
|
)
|
|
1413
1551
|
|
|
1414
1552
|
try:
|
|
1415
1553
|
request = TriggerSessionRequest(
|
|
1416
1554
|
user_id=user_id,
|
|
1417
1555
|
user_trigger=user_trigger,
|
|
1556
|
+
current_page=current_page,
|
|
1418
1557
|
)
|
|
1419
1558
|
|
|
1420
1559
|
response = await self._sessions.trigger_session(request)
|
|
@@ -1438,7 +1577,7 @@ class WebSocketServer:
|
|
|
1438
1577
|
"message": "failed to trigger session",
|
|
1439
1578
|
"error": "UPSTREAM_TRIGGER_FAILED",
|
|
1440
1579
|
}))
|
|
1441
|
-
return
|
|
1580
|
+
return None
|
|
1442
1581
|
|
|
1443
1582
|
response_data = {
|
|
1444
1583
|
"status": response.status,
|
|
@@ -1447,7 +1586,8 @@ class WebSocketServer:
|
|
|
1447
1586
|
"time_taken": response.time_taken,
|
|
1448
1587
|
}
|
|
1449
1588
|
|
|
1450
|
-
|
|
1589
|
+
frame = json.dumps(response_data)
|
|
1590
|
+
await websocket.send(frame)
|
|
1451
1591
|
|
|
1452
1592
|
safe_log(
|
|
1453
1593
|
self._logger,
|
|
@@ -1460,6 +1600,7 @@ class WebSocketServer:
|
|
|
1460
1600
|
"has_session_data": response.session_data is not None,
|
|
1461
1601
|
},
|
|
1462
1602
|
)
|
|
1603
|
+
return frame
|
|
1463
1604
|
|
|
1464
1605
|
except Exception as e:
|
|
1465
1606
|
# Generic on the wire, detailed in the server's own logs: upstream
|
|
@@ -109,6 +109,195 @@ 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
|
+
try:
|
|
131
|
+
async with websockets.connect(ws_url(ws_port, token)) as ws:
|
|
132
|
+
await ws.recv() # auto-trigger on connect: no page known yet
|
|
133
|
+
assert "current_page" not in json.loads(route.calls.last.request.content)
|
|
134
|
+
|
|
135
|
+
await ws.send(json.dumps({
|
|
136
|
+
"user_trigger": True,
|
|
137
|
+
"page": {"path": "/billing/42", "pattern": "/billing/:id", "title": "Invoices",
|
|
138
|
+
"referrer_path": "/home", "junk": "ignored"},
|
|
139
|
+
}))
|
|
140
|
+
data = json.loads(await asyncio.wait_for(ws.recv(), timeout=5.0))
|
|
141
|
+
assert data["status"] == "success"
|
|
142
|
+
|
|
143
|
+
body = json.loads(route.calls.last.request.content)
|
|
144
|
+
assert body["external_user_id"] == "user_123"
|
|
145
|
+
assert body["current_page"] == {
|
|
146
|
+
"path": "/billing/42", "pattern": "/billing/:id", "title": "Invoices", "referrer_path": "/home",
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
# A malformed hint costs nothing: the session is still triggered, without it.
|
|
150
|
+
await ws.send(json.dumps({"user_trigger": True, "page": {"pattern": 7}}))
|
|
151
|
+
data = json.loads(await asyncio.wait_for(ws.recv(), timeout=5.0))
|
|
152
|
+
assert data["status"] == "success"
|
|
153
|
+
assert "current_page" not in json.loads(route.calls.last.request.content)
|
|
154
|
+
|
|
155
|
+
finally:
|
|
156
|
+
await client.aclose()
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
@pytest.mark.asyncio
|
|
160
|
+
@respx.mock
|
|
161
|
+
async def test_a_client_that_asks_for_its_session_replaces_the_automatic_trigger(
|
|
162
|
+
base_url, api_key, ws_port, trigger_session_response, pause_status_not_paused_response
|
|
163
|
+
):
|
|
164
|
+
"""One Centive call per connection start, carrying the page.
|
|
165
|
+
|
|
166
|
+
Seen on dev: the connect-time automatic trigger and the browser SDK's own
|
|
167
|
+
request (sent the moment the socket opens) both reached Centive within
|
|
168
|
+
milliseconds, two Anam tokens per connection, and the opening prompt was
|
|
169
|
+
built from the page-less one.
|
|
170
|
+
"""
|
|
171
|
+
respx.get(f"{base_url}/anam/pause-status/user_123").mock(
|
|
172
|
+
return_value=httpx.Response(200, json=pause_status_not_paused_response)
|
|
173
|
+
)
|
|
174
|
+
route = respx.post(f"{base_url}/anam/trigger-session").mock(
|
|
175
|
+
return_value=httpx.Response(200, json=trigger_session_response)
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
client = AsyncCentiveClient(api_key=api_key, base_url=base_url)
|
|
179
|
+
token = await client.initialize_websocket(user_id="user_123", port=ws_port)
|
|
180
|
+
|
|
181
|
+
try:
|
|
182
|
+
async with websockets.connect(ws_url(ws_port, token)) as ws:
|
|
183
|
+
await ws.send(json.dumps({"user_trigger": True, "page": {"path": "/dashboard", "pattern": "/dashboard"}}))
|
|
184
|
+
data = json.loads(await asyncio.wait_for(ws.recv(), timeout=5.0))
|
|
185
|
+
assert data["status"] == "success"
|
|
186
|
+
|
|
187
|
+
# Past the grace period nothing else arrives: the automatic trigger stood down.
|
|
188
|
+
with pytest.raises(asyncio.TimeoutError):
|
|
189
|
+
await asyncio.wait_for(ws.recv(), timeout=client.config.session_trigger_grace_seconds + 0.7)
|
|
190
|
+
|
|
191
|
+
assert route.call_count == 1
|
|
192
|
+
assert json.loads(route.calls.last.request.content)["current_page"]["path"] == "/dashboard"
|
|
193
|
+
finally:
|
|
194
|
+
await client.aclose()
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
@pytest.mark.asyncio
|
|
198
|
+
@respx.mock
|
|
199
|
+
async def test_a_silent_client_still_gets_the_automatic_trigger(
|
|
200
|
+
base_url, api_key, ws_port, trigger_session_response, pause_status_not_paused_response
|
|
201
|
+
):
|
|
202
|
+
"""Older browser SDKs and custom frontends never ask; they get their session
|
|
203
|
+
after the grace period, from exactly one call."""
|
|
204
|
+
respx.get(f"{base_url}/anam/pause-status/user_123").mock(
|
|
205
|
+
return_value=httpx.Response(200, json=pause_status_not_paused_response)
|
|
206
|
+
)
|
|
207
|
+
route = respx.post(f"{base_url}/anam/trigger-session").mock(
|
|
208
|
+
return_value=httpx.Response(200, json=trigger_session_response)
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
client = AsyncCentiveClient(api_key=api_key, base_url=base_url)
|
|
212
|
+
token = await client.initialize_websocket(user_id="user_123", port=ws_port)
|
|
213
|
+
|
|
214
|
+
try:
|
|
215
|
+
async with websockets.connect(ws_url(ws_port, token)) as ws:
|
|
216
|
+
data = json.loads(await asyncio.wait_for(ws.recv(), timeout=5.0))
|
|
217
|
+
assert data["status"] == "success"
|
|
218
|
+
assert data["session_data"]["token"] == trigger_session_response["session_data"]["token"]
|
|
219
|
+
|
|
220
|
+
assert route.call_count == 1
|
|
221
|
+
assert "current_page" not in json.loads(route.calls.last.request.content)
|
|
222
|
+
finally:
|
|
223
|
+
await client.aclose()
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
@pytest.mark.asyncio
|
|
227
|
+
@respx.mock
|
|
228
|
+
async def test_concurrent_triggers_on_one_connection_share_a_call(
|
|
229
|
+
base_url, api_key, ws_port, trigger_session_response, pause_status_not_paused_response
|
|
230
|
+
):
|
|
231
|
+
"""A request that lands while another trigger for the connection is in
|
|
232
|
+
flight waits for it and receives the same frame; Centive is called once."""
|
|
233
|
+
respx.get(f"{base_url}/anam/pause-status/user_123").mock(
|
|
234
|
+
return_value=httpx.Response(200, json=pause_status_not_paused_response)
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
async def slow(request):
|
|
238
|
+
await asyncio.sleep(0.4)
|
|
239
|
+
return httpx.Response(200, json=trigger_session_response)
|
|
240
|
+
|
|
241
|
+
route = respx.post(f"{base_url}/anam/trigger-session").mock(side_effect=slow)
|
|
242
|
+
|
|
243
|
+
client = AsyncCentiveClient(api_key=api_key, base_url=base_url)
|
|
244
|
+
client.config.session_trigger_grace_seconds = 0 # automatic trigger fires at once
|
|
245
|
+
token = await client.initialize_websocket(user_id="user_123", port=ws_port)
|
|
246
|
+
|
|
247
|
+
try:
|
|
248
|
+
async with websockets.connect(ws_url(ws_port, token)) as ws:
|
|
249
|
+
await ws.send(json.dumps({"user_trigger": True})) # lands while the automatic call is in flight
|
|
250
|
+
first = json.loads(await asyncio.wait_for(ws.recv(), timeout=5.0))
|
|
251
|
+
second = json.loads(await asyncio.wait_for(ws.recv(), timeout=5.0))
|
|
252
|
+
assert first["status"] == second["status"] == "success"
|
|
253
|
+
assert first["session_data"] == second["session_data"]
|
|
254
|
+
|
|
255
|
+
assert route.call_count == 1
|
|
256
|
+
finally:
|
|
257
|
+
await client.aclose()
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
@pytest.mark.asyncio
|
|
261
|
+
@respx.mock
|
|
262
|
+
async def test_user_trigger_falls_back_to_the_last_page_view_seen(
|
|
263
|
+
base_url, api_key, ws_port, trigger_session_response, pause_status_not_paused_response, telemetry_event_data
|
|
264
|
+
):
|
|
265
|
+
"""A browser SDK that predates the `page` field still gets the right opening
|
|
266
|
+
page: the last page_view on the connection stands in."""
|
|
267
|
+
respx.get(f"{base_url}/anam/pause-status/user_123").mock(
|
|
268
|
+
return_value=httpx.Response(200, json=pause_status_not_paused_response)
|
|
269
|
+
)
|
|
270
|
+
route = respx.post(f"{base_url}/anam/trigger-session").mock(
|
|
271
|
+
return_value=httpx.Response(200, json=trigger_session_response)
|
|
272
|
+
)
|
|
273
|
+
respx.post(f"{base_url}/anam/events").mock(
|
|
274
|
+
return_value=httpx.Response(202, json={"accepted": 2, "rejected": 0, "rejections": [], "context": []})
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
client = AsyncCentiveClient(api_key=api_key, base_url=base_url)
|
|
278
|
+
token = await client.initialize_websocket(user_id="user_123", port=ws_port)
|
|
279
|
+
|
|
280
|
+
try:
|
|
281
|
+
async with websockets.connect(ws_url(ws_port, token)) as ws:
|
|
282
|
+
await ws.recv()
|
|
283
|
+
frame = dict(telemetry_event_data)
|
|
284
|
+
await ws.send(json.dumps(frame))
|
|
285
|
+
ack = json.loads(await asyncio.wait_for(ws.recv(), timeout=5.0))
|
|
286
|
+
assert ack["type"] == "telemetry_ack"
|
|
287
|
+
|
|
288
|
+
await ws.send(json.dumps({"user_trigger": True}))
|
|
289
|
+
data = json.loads(await asyncio.wait_for(ws.recv(), timeout=5.0))
|
|
290
|
+
assert data["status"] == "success"
|
|
291
|
+
|
|
292
|
+
body = json.loads(route.calls.last.request.content)
|
|
293
|
+
latest_view = [e for e in frame["events"] if e["event_type"] == "page_view"][-1]["page"]
|
|
294
|
+
assert body["current_page"]["path"] == latest_view["path"]
|
|
295
|
+
assert body["current_page"]["pattern"] == latest_view["pattern"]
|
|
296
|
+
|
|
297
|
+
finally:
|
|
298
|
+
await client.aclose()
|
|
299
|
+
|
|
300
|
+
|
|
112
301
|
@pytest.mark.asyncio
|
|
113
302
|
@respx.mock
|
|
114
303
|
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
|
{centive_sdk-2.2.0.dev5 → centive_sdk-2.2.0.dev8}/src/centive_sdk/resources/async_sessions.py
RENAMED
|
File without changes
|
{centive_sdk-2.2.0.dev5 → centive_sdk-2.2.0.dev8}/src/centive_sdk/resources/async_telemetry.py
RENAMED
|
File without changes
|
{centive_sdk-2.2.0.dev5 → centive_sdk-2.2.0.dev8}/src/centive_sdk/resources/message_accumulator.py
RENAMED
|
File without changes
|
|
File without changes
|
{centive_sdk-2.2.0.dev5 → centive_sdk-2.2.0.dev8}/src/centive_sdk/resources/telemetry_batcher.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
|