centive-sdk 2.1.0.dev2__tar.gz → 2.1.0.dev3__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.
Files changed (44) hide show
  1. {centive_sdk-2.1.0.dev2 → centive_sdk-2.1.0.dev3}/CHANGELOG.md +41 -0
  2. {centive_sdk-2.1.0.dev2 → centive_sdk-2.1.0.dev3}/PKG-INFO +85 -1
  3. {centive_sdk-2.1.0.dev2 → centive_sdk-2.1.0.dev3}/README.md +84 -0
  4. {centive_sdk-2.1.0.dev2 → centive_sdk-2.1.0.dev3}/pyproject.toml +1 -1
  5. {centive_sdk-2.1.0.dev2 → centive_sdk-2.1.0.dev3}/src/centive_sdk/async_client.py +9 -0
  6. {centive_sdk-2.1.0.dev2 → centive_sdk-2.1.0.dev3}/src/centive_sdk/config.py +10 -0
  7. {centive_sdk-2.1.0.dev2 → centive_sdk-2.1.0.dev3}/src/centive_sdk/resources/websocket_server.py +225 -1
  8. {centive_sdk-2.1.0.dev2 → centive_sdk-2.1.0.dev3}/tests/conftest.py +36 -0
  9. {centive_sdk-2.1.0.dev2 → centive_sdk-2.1.0.dev3}/tests/test_websocket_security.py +233 -0
  10. centive_sdk-2.1.0.dev3/tests/test_websocket_server.py +969 -0
  11. centive_sdk-2.1.0.dev2/tests/test_websocket_server.py +0 -419
  12. {centive_sdk-2.1.0.dev2 → centive_sdk-2.1.0.dev3}/.gitignore +0 -0
  13. {centive_sdk-2.1.0.dev2 → centive_sdk-2.1.0.dev3}/LICENSE +0 -0
  14. {centive_sdk-2.1.0.dev2 → centive_sdk-2.1.0.dev3}/blackbox/README.md +0 -0
  15. {centive_sdk-2.1.0.dev2 → centive_sdk-2.1.0.dev3}/blackbox/customer_app.py +0 -0
  16. {centive_sdk-2.1.0.dev2 → centive_sdk-2.1.0.dev3}/blackbox/harness.py +0 -0
  17. {centive_sdk-2.1.0.dev2 → centive_sdk-2.1.0.dev3}/blackbox/mock_centive_api.py +0 -0
  18. {centive_sdk-2.1.0.dev2 → centive_sdk-2.1.0.dev3}/blackbox/test_blackbox.py +0 -0
  19. {centive_sdk-2.1.0.dev2 → centive_sdk-2.1.0.dev3}/examples/async_example.py +0 -0
  20. {centive_sdk-2.1.0.dev2 → centive_sdk-2.1.0.dev3}/examples/fastapi_integration.py +0 -0
  21. {centive_sdk-2.1.0.dev2 → centive_sdk-2.1.0.dev3}/examples/sync_example.py +0 -0
  22. {centive_sdk-2.1.0.dev2 → centive_sdk-2.1.0.dev3}/examples/websocket_example.py +0 -0
  23. {centive_sdk-2.1.0.dev2 → centive_sdk-2.1.0.dev3}/src/centive_sdk/__init__.py +0 -0
  24. {centive_sdk-2.1.0.dev2 → centive_sdk-2.1.0.dev3}/src/centive_sdk/_http.py +0 -0
  25. {centive_sdk-2.1.0.dev2 → centive_sdk-2.1.0.dev3}/src/centive_sdk/_logging.py +0 -0
  26. {centive_sdk-2.1.0.dev2 → centive_sdk-2.1.0.dev3}/src/centive_sdk/_retry.py +0 -0
  27. {centive_sdk-2.1.0.dev2 → centive_sdk-2.1.0.dev3}/src/centive_sdk/client.py +0 -0
  28. {centive_sdk-2.1.0.dev2 → centive_sdk-2.1.0.dev3}/src/centive_sdk/exceptions.py +0 -0
  29. {centive_sdk-2.1.0.dev2 → centive_sdk-2.1.0.dev3}/src/centive_sdk/models/__init__.py +0 -0
  30. {centive_sdk-2.1.0.dev2 → centive_sdk-2.1.0.dev3}/src/centive_sdk/models/requests.py +0 -0
  31. {centive_sdk-2.1.0.dev2 → centive_sdk-2.1.0.dev3}/src/centive_sdk/models/responses.py +0 -0
  32. {centive_sdk-2.1.0.dev2 → centive_sdk-2.1.0.dev3}/src/centive_sdk/py.typed +0 -0
  33. {centive_sdk-2.1.0.dev2 → centive_sdk-2.1.0.dev3}/src/centive_sdk/resources/__init__.py +0 -0
  34. {centive_sdk-2.1.0.dev2 → centive_sdk-2.1.0.dev3}/src/centive_sdk/resources/async_sessions.py +0 -0
  35. {centive_sdk-2.1.0.dev2 → centive_sdk-2.1.0.dev3}/src/centive_sdk/resources/message_accumulator.py +0 -0
  36. {centive_sdk-2.1.0.dev2 → centive_sdk-2.1.0.dev3}/src/centive_sdk/resources/sessions.py +0 -0
  37. {centive_sdk-2.1.0.dev2 → centive_sdk-2.1.0.dev3}/tests/__init__.py +0 -0
  38. {centive_sdk-2.1.0.dev2 → centive_sdk-2.1.0.dev3}/tests/test_client_async.py +0 -0
  39. {centive_sdk-2.1.0.dev2 → centive_sdk-2.1.0.dev3}/tests/test_client_sync.py +0 -0
  40. {centive_sdk-2.1.0.dev2 → centive_sdk-2.1.0.dev3}/tests/test_config.py +0 -0
  41. {centive_sdk-2.1.0.dev2 → centive_sdk-2.1.0.dev3}/tests/test_error_mapping.py +0 -0
  42. {centive_sdk-2.1.0.dev2 → centive_sdk-2.1.0.dev3}/tests/test_logging_redaction.py +0 -0
  43. {centive_sdk-2.1.0.dev2 → centive_sdk-2.1.0.dev3}/tests/test_message_accumulator.py +0 -0
  44. {centive_sdk-2.1.0.dev2 → centive_sdk-2.1.0.dev3}/tests/test_retry_logic.py +0 -0
@@ -2,6 +2,47 @@
2
2
 
3
3
  All notable changes to this project are documented in this file.
4
4
 
5
+ ## v2.1.0
6
+
7
+ Unreleased
8
+
9
+ ### Added
10
+
11
+ - **Page-telemetry frames from the FE SDK are now handled.**
12
+ `@centive/aria-sdk` 1.1.0+ sends `{"type": "telemetry", ...}` page-view and
13
+ page-leave events over the existing Aria websocket. The server previously had
14
+ no branch for them, so every event fell through to the generic
15
+ `"message received"` reply and was discarded.
16
+ - **`on_telemetry` client option.** Pass a callable to `AsyncCentiveClient` —
17
+ `on_telemetry(user_id: str, frame: dict)`, sync or async, with the events at
18
+ `frame["events"]` and the producer's `frame["schema"]` / `frame["sdk"]`
19
+ alongside them — to receive those events. The SDK relays and forgets:
20
+ nothing is stored and nothing is sent to the Centive API. Without a callback
21
+ the frame is acknowledged with `accepted: 0` and dropped.
22
+ - **Events are attributed to the connection's bound user**, not to the frame's
23
+ `user_id`. A forged `user_id` is rejected with `IDENTITY_MISMATCH`, as on
24
+ every other frame type. Telemetry carries a `browser_session_id` (a client
25
+ tab) rather than a chat `session_id`, so no session is created or looked up.
26
+ - **The producer's caps are mirrored server-side**:
27
+ `max_telemetry_events_per_frame` (100), `max_telemetry_frame_bytes` (65536)
28
+ and `max_telemetry_properties_bytes` (4096). The transport cap
29
+ (`ws_max_size`, 1 MiB) is far too loose to catch a buggy or hostile client.
30
+ A frame over a cap is **acknowledged, not errored** —
31
+ `{"type": "telemetry_ack", "accepted": 0, "dropped": N, "reason": ...}` with
32
+ `reason` one of `frame_too_large`, `too_many_events`, `properties_too_large`
33
+ — because the frontend parses `telemetry_ack` and ignores error frames, so a
34
+ cap breach reported as an error would be invisible to it.
35
+ - A **malformed** frame still takes the ordinary error channel:
36
+ `INVALID_TELEMETRY_FORMAT` for missing or wrong-typed required fields.
37
+ - **The schema version is forwarded, not gated on.** `frame["schema"]` is
38
+ checked for shape and passed through, so a future producer version whose
39
+ events are still well-formed keeps working against this SDK rather than
40
+ requiring a lockstep backend deploy.
41
+ - `event_type` is treated as an open set: an event kind the server does not
42
+ recognize is forwarded, not rejected.
43
+ - A callback that raises cannot close the connection. The frame is acked as
44
+ dropped with `reason: "handler_error"` and the error goes to the server log.
45
+
5
46
  ## v2.0.0
6
47
 
7
48
  Released: 2026-08-11
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: centive-sdk
3
- Version: 2.1.0.dev2
3
+ Version: 2.1.0.dev3
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
@@ -40,6 +40,7 @@ Production-quality Python SDK for integrating Centive into your backend services
40
40
  - ✅ **Sync & Async Support** - Use `CentiveClient` for synchronous code or `AsyncCentiveClient` for async/await
41
41
  - ✅ **WebSocket Server** - Real-time communication with frontend SDKs
42
42
  - ✅ **Message Accumulation** - Capture and save Anam AI conversation messages automatically
43
+ - ✅ **Page Telemetry** - Receive FE SDK page-view events through an `on_telemetry` callback
43
44
  - ✅ **Automatic Retries** - Exponential backoff with jitter for transient failures
44
45
  - ✅ **Type Safety** - Full Pydantic validation and type hints throughout
45
46
  - ✅ **Error Handling** - Comprehensive typed exceptions for all error scenarios
@@ -205,10 +206,14 @@ an expired connection token.
205
206
  | `max_message_content_chars` | `int` | `32768` | longest single message accepted |
206
207
  | `max_session_id_chars` | `int` | `200` | longest `session_id` accepted |
207
208
  | `max_connections_per_user` | `int` | `5` | concurrent websocket connections per user |
209
+ | `max_telemetry_events_per_frame` | `int` | `100` | events accepted per telemetry frame |
210
+ | `max_telemetry_frame_bytes` | `int` | `65536` | largest telemetry frame accepted (UTF-8 bytes) |
211
+ | `max_telemetry_properties_bytes` | `int` | `4096` | serialized `properties` per telemetry event |
208
212
  | `pause_check_fail_open` | `bool` | `True` | proceed as active if the pause check fails; `False` withholds access |
209
213
  | `circuit_breaker_threshold` | `int` | `5` | failures before circuit opens |
210
214
  | `circuit_breaker_recovery_seconds` | `float` | `60.0` | time before circuit recovery attempt |
211
215
  | `logger` | `Optional[Any]` | `None` | custom logger instance for debugging |
216
+ | `on_telemetry` | `Optional[Callable]` | `None` | host sink for page-telemetry frames (see Page Telemetry) |
212
217
 
213
218
  ### Example with Custom Configuration
214
219
 
@@ -513,6 +518,85 @@ client = AsyncCentiveClient(
513
518
 
514
519
  For details on WebSocket event formats, see `websocket_messages.md`.
515
520
 
521
+ ## Page Telemetry
522
+
523
+ The FE SDK (`@centive/aria-sdk` 1.1.0+) sends page-view and page-leave events over
524
+ the same websocket as the chat traffic. The BE SDK validates them, attributes them
525
+ to the connection's authenticated user, and hands them to a callback you supply.
526
+ **It stores nothing and forwards nothing to the Centive API** — where the events go
527
+ is entirely your decision.
528
+
529
+ Your callback receives two arguments: `user_id`, the identity bound to the websocket
530
+ connection (never a value the browser claimed), and `frame`, the validated frame.
531
+ The events are at `frame["events"]`; `frame["schema"]` and `frame["sdk"]` carry the
532
+ producer's schema version and SDK version, which are worth storing alongside them.
533
+
534
+ ```python
535
+ async def record_page_events(user_id: str, frame: dict) -> None:
536
+ await analytics.insert_many(
537
+ {**event, "user_id": user_id, "sdk": frame.get("sdk")}
538
+ for event in frame["events"]
539
+ )
540
+
541
+ client = AsyncCentiveClient(
542
+ api_key="sk_live_your_api_key",
543
+ on_telemetry=record_page_events, # sync callables work too
544
+ )
545
+ ```
546
+
547
+ `**event` is spread *first*, never last: events are forwarded verbatim with
548
+ no key filtering, so a client-supplied `user_id` inside an event would
549
+ otherwise overwrite the identity the server already verified. Keep the
550
+ trusted `user_id` and `sdk` after the spread.
551
+
552
+ Each event carries `client_event_id`, `event_type`, `occurred_at`,
553
+ `browser_session_id`, `detection`, and usually a `page` object
554
+ (`path`, `pattern`, `title`, `referrer_path`) plus `duration_ms` on `page_leave`.
555
+ `event_type` is an open set — new client-side event kinds reach your callback
556
+ rather than being rejected, so switch on the values you know and ignore the rest.
557
+ Each of `client_event_id`, `event_type`, `occurred_at` and `browser_session_id`
558
+ is capped at 256 characters; over that, the whole frame is rejected with
559
+ `INVALID_TELEMETRY_FORMAT` rather than truncated, so keep custom `event_type`
560
+ values well under that.
561
+
562
+ `telemetry_ack` carries one of five `reason` values whenever `accepted` is
563
+ less than the event count:
564
+
565
+ | `reason` | Meaning |
566
+ | --- | --- |
567
+ | `frame_too_large` | frame exceeded the 64 KB wire-size cap |
568
+ | `too_many_events` | frame exceeded the 100-events-per-frame cap |
569
+ | `properties_too_large` | one event's `properties` exceeded the 4 KB cap |
570
+ | `no_telemetry_handler` | no `on_telemetry` callback is configured |
571
+ | `handler_error` | the callback raised; see your logger for the exception |
572
+
573
+ Behavior worth knowing:
574
+
575
+ - **Without `on_telemetry`, events are dropped.** The frame is still acknowledged,
576
+ with `accepted: 0` and `reason: "no_telemetry_handler"`, so the frontend never
577
+ stalls waiting on a sink that does not exist.
578
+ - **A callback that raises cannot break the connection.** The frame is acked as
579
+ dropped (`reason: "handler_error"`) and the exception goes to your logger.
580
+ - **A sync callback runs on the event loop.** If yours does real work — a database
581
+ write, an HTTP call — make it `async`, or hand off to a queue.
582
+ - **The producer's own caps are enforced here too** (100 events per frame, 64 KB
583
+ per frame, 4 KB of `properties` per event). An over-cap frame never reaches your
584
+ callback; it is acknowledged with `accepted: 0` and a `reason` of
585
+ `too_many_events`, `frame_too_large` or `properties_too_large`. A *malformed*
586
+ frame gets the ordinary error response instead (`INVALID_TELEMETRY_FORMAT`).
587
+ - **A newer `schema` is not an error.** The SDK does not gate on the version, so
588
+ a future FE SDK release keeps working without a backend deploy. Branch on
589
+ `frame["schema"]` yourself if your storage ever needs to.
590
+ - **`properties._dropped`** is a counter the browser SDK reports when its own buffer
591
+ had to evict events. It is relayed to your callback verbatim; the SDK never acts
592
+ on it.
593
+ - **`properties._properties_dropped`** replaces the entire `properties` object with
594
+ `{"_properties_dropped": true}` when the browser's own properties blob exceeded
595
+ 4 KB before it was ever sent. Unlike `_dropped`, this is not a count alongside
596
+ intact data — it means the original properties are gone entirely, not truncated.
597
+ - **There is no rate limiting.** The frontend understands a
598
+ `reason: "rate_limited"` ack, but the SDK never sends one today.
599
+
516
600
  ## Advanced Usage
517
601
 
518
602
  ### Concurrent Tool Mapping (Async)
@@ -7,6 +7,7 @@ Production-quality Python SDK for integrating Centive into your backend services
7
7
  - ✅ **Sync & Async Support** - Use `CentiveClient` for synchronous code or `AsyncCentiveClient` for async/await
8
8
  - ✅ **WebSocket Server** - Real-time communication with frontend SDKs
9
9
  - ✅ **Message Accumulation** - Capture and save Anam AI conversation messages automatically
10
+ - ✅ **Page Telemetry** - Receive FE SDK page-view events through an `on_telemetry` callback
10
11
  - ✅ **Automatic Retries** - Exponential backoff with jitter for transient failures
11
12
  - ✅ **Type Safety** - Full Pydantic validation and type hints throughout
12
13
  - ✅ **Error Handling** - Comprehensive typed exceptions for all error scenarios
@@ -172,10 +173,14 @@ an expired connection token.
172
173
  | `max_message_content_chars` | `int` | `32768` | longest single message accepted |
173
174
  | `max_session_id_chars` | `int` | `200` | longest `session_id` accepted |
174
175
  | `max_connections_per_user` | `int` | `5` | concurrent websocket connections per user |
176
+ | `max_telemetry_events_per_frame` | `int` | `100` | events accepted per telemetry frame |
177
+ | `max_telemetry_frame_bytes` | `int` | `65536` | largest telemetry frame accepted (UTF-8 bytes) |
178
+ | `max_telemetry_properties_bytes` | `int` | `4096` | serialized `properties` per telemetry event |
175
179
  | `pause_check_fail_open` | `bool` | `True` | proceed as active if the pause check fails; `False` withholds access |
176
180
  | `circuit_breaker_threshold` | `int` | `5` | failures before circuit opens |
177
181
  | `circuit_breaker_recovery_seconds` | `float` | `60.0` | time before circuit recovery attempt |
178
182
  | `logger` | `Optional[Any]` | `None` | custom logger instance for debugging |
183
+ | `on_telemetry` | `Optional[Callable]` | `None` | host sink for page-telemetry frames (see Page Telemetry) |
179
184
 
180
185
  ### Example with Custom Configuration
181
186
 
@@ -480,6 +485,85 @@ client = AsyncCentiveClient(
480
485
 
481
486
  For details on WebSocket event formats, see `websocket_messages.md`.
482
487
 
488
+ ## Page Telemetry
489
+
490
+ The FE SDK (`@centive/aria-sdk` 1.1.0+) sends page-view and page-leave events over
491
+ the same websocket as the chat traffic. The BE SDK validates them, attributes them
492
+ to the connection's authenticated user, and hands them to a callback you supply.
493
+ **It stores nothing and forwards nothing to the Centive API** — where the events go
494
+ is entirely your decision.
495
+
496
+ Your callback receives two arguments: `user_id`, the identity bound to the websocket
497
+ connection (never a value the browser claimed), and `frame`, the validated frame.
498
+ The events are at `frame["events"]`; `frame["schema"]` and `frame["sdk"]` carry the
499
+ producer's schema version and SDK version, which are worth storing alongside them.
500
+
501
+ ```python
502
+ async def record_page_events(user_id: str, frame: dict) -> None:
503
+ await analytics.insert_many(
504
+ {**event, "user_id": user_id, "sdk": frame.get("sdk")}
505
+ for event in frame["events"]
506
+ )
507
+
508
+ client = AsyncCentiveClient(
509
+ api_key="sk_live_your_api_key",
510
+ on_telemetry=record_page_events, # sync callables work too
511
+ )
512
+ ```
513
+
514
+ `**event` is spread *first*, never last: events are forwarded verbatim with
515
+ no key filtering, so a client-supplied `user_id` inside an event would
516
+ otherwise overwrite the identity the server already verified. Keep the
517
+ trusted `user_id` and `sdk` after the spread.
518
+
519
+ Each event carries `client_event_id`, `event_type`, `occurred_at`,
520
+ `browser_session_id`, `detection`, and usually a `page` object
521
+ (`path`, `pattern`, `title`, `referrer_path`) plus `duration_ms` on `page_leave`.
522
+ `event_type` is an open set — new client-side event kinds reach your callback
523
+ rather than being rejected, so switch on the values you know and ignore the rest.
524
+ Each of `client_event_id`, `event_type`, `occurred_at` and `browser_session_id`
525
+ is capped at 256 characters; over that, the whole frame is rejected with
526
+ `INVALID_TELEMETRY_FORMAT` rather than truncated, so keep custom `event_type`
527
+ values well under that.
528
+
529
+ `telemetry_ack` carries one of five `reason` values whenever `accepted` is
530
+ less than the event count:
531
+
532
+ | `reason` | Meaning |
533
+ | --- | --- |
534
+ | `frame_too_large` | frame exceeded the 64 KB wire-size cap |
535
+ | `too_many_events` | frame exceeded the 100-events-per-frame cap |
536
+ | `properties_too_large` | one event's `properties` exceeded the 4 KB cap |
537
+ | `no_telemetry_handler` | no `on_telemetry` callback is configured |
538
+ | `handler_error` | the callback raised; see your logger for the exception |
539
+
540
+ Behavior worth knowing:
541
+
542
+ - **Without `on_telemetry`, events are dropped.** The frame is still acknowledged,
543
+ with `accepted: 0` and `reason: "no_telemetry_handler"`, so the frontend never
544
+ stalls waiting on a sink that does not exist.
545
+ - **A callback that raises cannot break the connection.** The frame is acked as
546
+ dropped (`reason: "handler_error"`) and the exception goes to your logger.
547
+ - **A sync callback runs on the event loop.** If yours does real work — a database
548
+ write, an HTTP call — make it `async`, or hand off to a queue.
549
+ - **The producer's own caps are enforced here too** (100 events per frame, 64 KB
550
+ per frame, 4 KB of `properties` per event). An over-cap frame never reaches your
551
+ callback; it is acknowledged with `accepted: 0` and a `reason` of
552
+ `too_many_events`, `frame_too_large` or `properties_too_large`. A *malformed*
553
+ frame gets the ordinary error response instead (`INVALID_TELEMETRY_FORMAT`).
554
+ - **A newer `schema` is not an error.** The SDK does not gate on the version, so
555
+ a future FE SDK release keeps working without a backend deploy. Branch on
556
+ `frame["schema"]` yourself if your storage ever needs to.
557
+ - **`properties._dropped`** is a counter the browser SDK reports when its own buffer
558
+ had to evict events. It is relayed to your callback verbatim; the SDK never acts
559
+ on it.
560
+ - **`properties._properties_dropped`** replaces the entire `properties` object with
561
+ `{"_properties_dropped": true}` when the browser's own properties blob exceeded
562
+ 4 KB before it was ever sent. Unlike `_dropped`, this is not a count alongside
563
+ intact data — it means the original properties are gone entirely, not truncated.
564
+ - **There is no rate limiting.** The frontend understands a
565
+ `reason: "rate_limited"` ack, but the SDK never sends one today.
566
+
483
567
  ## Advanced Usage
484
568
 
485
569
  ### Concurrent Tool Mapping (Async)
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "centive-sdk"
7
- version = "2.1.0.dev2"
7
+ version = "2.1.0.dev3"
8
8
  description = "Python SDK for Centive backend integration"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -40,6 +40,11 @@ class AsyncCentiveClient:
40
40
 
41
41
  api_key and base_url fall back to the CENTIVE_API_KEY and CENTIVE_BASE_URL
42
42
  environment variables when not passed explicitly.
43
+
44
+ Pass on_telemetry=<callable> to receive page-telemetry frames from the FE
45
+ SDK: on_telemetry(user_id: str, frame: dict), sync or async, with the
46
+ events at frame["events"]. The SDK does not store them — without a
47
+ callback they are acknowledged and dropped.
43
48
  """
44
49
  if api_key is not None:
45
50
  options["api_key"] = api_key
@@ -48,6 +53,9 @@ class AsyncCentiveClient:
48
53
  self.config = ClientConfig(**options)
49
54
  self._http_client = httpx.AsyncClient()
50
55
  self._logger = options.get("logger")
56
+ # Same pattern as logger: a callable, not config data, so it rides in
57
+ # **options and is read out here. ClientConfig ignores unknown keys.
58
+ self._on_telemetry = options.get("on_telemetry")
51
59
  self.sessions = AsyncSessions(self._http_client, self.config, self._logger)
52
60
  self._ws_server: Optional[WebSocketServer] = None
53
61
  self._last_pause_status: Optional[PauseStatusResponse] = None
@@ -128,6 +136,7 @@ class AsyncCentiveClient:
128
136
  sessions=self.sessions,
129
137
  config=self.config,
130
138
  logger=self._logger,
139
+ on_telemetry=self._on_telemetry,
131
140
  )
132
141
  # Publish only after a successful bind, so a failed start does
133
142
  # not leave a dead server in place and wedge every later call.
@@ -83,6 +83,16 @@ class ClientConfig(BaseModel):
83
83
  # Concurrent websocket connections accepted per user.
84
84
  max_connections_per_user: int = 5
85
85
 
86
+ # Page-telemetry frames (type="telemetry") from the FE SDK. These mirror the
87
+ # aria-sdk producer's own caps (MAX_EVENTS_PER_FRAME, MAX_FRAME_BYTES,
88
+ # MAX_PROPERTIES_BYTES). They bound what reaches the on_telemetry callback
89
+ # and keep the ack honest against the producer's own limits — the frame
90
+ # itself is still parsed up to the far looser ws_max_size (1 MiB) transport
91
+ # cap regardless of these.
92
+ max_telemetry_events_per_frame: int = 100
93
+ max_telemetry_frame_bytes: int = 65_536
94
+ max_telemetry_properties_bytes: int = 4_096
95
+
86
96
  # When the pause-status check fails (upstream 5xx, rate limit, network), the
87
97
  # SDK proceeds as active so a Centive outage does not hide every avatar. Set
88
98
  # False to fail closed and withhold connections instead.
@@ -1,9 +1,10 @@
1
+ import inspect
1
2
  import json
2
3
  import secrets
3
4
  import time
4
5
  import warnings
5
6
  from collections import deque
6
- from typing import TYPE_CHECKING, Any, Deque, Dict, Optional, Set, Tuple
7
+ from typing import TYPE_CHECKING, Any, Callable, Deque, Dict, Optional, Set, Tuple
7
8
  from urllib.parse import parse_qs, urlparse
8
9
 
9
10
  import websockets
@@ -23,6 +24,22 @@ if TYPE_CHECKING:
23
24
 
24
25
  from .message_accumulator import MessageAccumulator
25
26
 
27
+ # Short identifier fields on a telemetry event. Not a config knob: the frame
28
+ # byte cap already bounds the whole payload, this only stops one field from
29
+ # eating the entire budget.
30
+ _MAX_TELEMETRY_FIELD_CHARS = 256
31
+
32
+ # The fields the producer always populates (TA-aria-sdk src/core/types.ts:40-49).
33
+ # event_type is checked for presence and shape only — never against a value set.
34
+ # The producer's own types call the set open, so an event kind added on the
35
+ # client must reach the host callback rather than be rejected here.
36
+ _TELEMETRY_REQUIRED_FIELDS = (
37
+ "client_event_id",
38
+ "event_type",
39
+ "occurred_at",
40
+ "browser_session_id",
41
+ )
42
+
26
43
 
27
44
  class WebSocketServer:
28
45
  """websocket server for FE SDK connections, session triggers, and message accumulation.
@@ -36,10 +53,16 @@ class WebSocketServer:
36
53
  sessions: "AsyncSessions",
37
54
  config: ClientConfig,
38
55
  logger: Optional[Any] = None,
56
+ on_telemetry: Optional[Callable[[str, dict], Any]] = None,
39
57
  ):
40
58
  self._sessions = sessions
41
59
  self._config = config
42
60
  self._logger = logger
61
+ # Host sink for page-telemetry frames. The SDK relays and forgets them:
62
+ # there is no Centive telemetry endpoint and nothing is stored here.
63
+ # Called as on_telemetry(user_id, frame); may be sync or async. See
64
+ # _handle_telemetry.
65
+ self._on_telemetry = on_telemetry
43
66
  self._server: Optional[websockets.WebSocketServer] = None
44
67
  self._clients: Set[WebSocketServerProtocol] = set()
45
68
  self._running = False
@@ -173,6 +196,82 @@ class WebSocketServer:
173
196
  return "MESSAGE_TOO_LARGE"
174
197
  return None
175
198
 
199
+ def _validate_telemetry(self, data: dict, raw_message: Any) -> Optional[Tuple[str, str]]:
200
+ """Validates a page-telemetry frame.
201
+
202
+ Returns None when the frame is acceptable, else a (kind, code) pair. The
203
+ two kinds leave the handler by different channels on purpose:
204
+
205
+ - ("cap", <reason>): well-formed but over one of the producer's own
206
+ limits. Acked with that reason, because the producer parses
207
+ telemetry_ack and ignores everything else (TA-aria-sdk
208
+ src/core/queue.ts:65-71) — a cap breach sent as an error frame would
209
+ be invisible to it. Flow-control signals added later (rate limiting)
210
+ belong in this same field.
211
+ - ("invalid", <ERROR_CODE>): a protocol error, which goes where every
212
+ other protocol error in this server goes, an "error" frame.
213
+
214
+ The caps mirror the producer's (100 events, 64 KB frame, 4 KB properties
215
+ per event). They bound what reaches the on_telemetry callback and keep
216
+ the ack honest against the producer's own limits — they do not bound
217
+ what the server parses: _process_message already ran json.loads on the
218
+ whole frame, up to the far looser ws_max_size (1 MiB) transport cap,
219
+ before this check ever runs. The frame is measured on the raw wire
220
+ bytes for the same reason the producer measures it there:
221
+ re-serializing the parsed object would not reproduce what was sent.
222
+ """
223
+ raw = raw_message.encode("utf-8") if isinstance(raw_message, str) else raw_message
224
+ if len(raw) > self._config.max_telemetry_frame_bytes:
225
+ return ("cap", "frame_too_large")
226
+
227
+ # Shape-checked and forwarded, never gated on its value: rejecting an
228
+ # unknown version would make a producer bump a lockstep deploy, and the
229
+ # per-event checks below reject an incompatible shape on its own merits.
230
+ # isinstance(x, int) is also true for bool, so that's excluded explicitly.
231
+ schema = data.get("schema")
232
+ if not isinstance(schema, int) or isinstance(schema, bool):
233
+ return ("invalid", "INVALID_TELEMETRY_FORMAT")
234
+
235
+ # The producer always populates sdk (TA-aria-sdk src/core/types.ts:40-49)
236
+ # and the README tells hosts to read frame["sdk"], so a frame missing it
237
+ # must be rejected here rather than reach the callback and KeyError.
238
+ sdk = data.get("sdk")
239
+ if not isinstance(sdk, str) or not sdk:
240
+ return ("invalid", "INVALID_TELEMETRY_FORMAT")
241
+
242
+ events = data.get("events")
243
+ if not isinstance(events, list) or not events:
244
+ return ("invalid", "INVALID_TELEMETRY_FORMAT")
245
+ if len(events) > self._config.max_telemetry_events_per_frame:
246
+ return ("cap", "too_many_events")
247
+
248
+ properties_limit = self._config.max_telemetry_properties_bytes
249
+
250
+ for event in events:
251
+ if not isinstance(event, dict):
252
+ return ("invalid", "INVALID_TELEMETRY_FORMAT")
253
+
254
+ for field in _TELEMETRY_REQUIRED_FIELDS:
255
+ value = event.get(field)
256
+ if not isinstance(value, str) or not value:
257
+ return ("invalid", "INVALID_TELEMETRY_FORMAT")
258
+ if len(value) > _MAX_TELEMETRY_FIELD_CHARS:
259
+ return ("invalid", "INVALID_TELEMETRY_FORMAT")
260
+
261
+ properties = event.get("properties")
262
+ if properties is None:
263
+ continue
264
+ if not isinstance(properties, dict):
265
+ return ("invalid", "INVALID_TELEMETRY_FORMAT")
266
+ # No spaces, matching JSON.stringify: json.dumps's default separators
267
+ # add a byte per comma/colon that the producer's own size check never
268
+ # counts, which would cap a payload the producer judged to be fine.
269
+ properties_json = json.dumps(properties, separators=(",", ":"))
270
+ if len(properties_json.encode("utf-8")) > properties_limit:
271
+ return ("cap", "properties_too_large")
272
+
273
+ return None
274
+
176
275
  def _purge_expired_tokens(self) -> None:
177
276
  now = time.monotonic()
178
277
  expired = [t for t, (_, exp) in self._connection_tokens.items() if exp <= now]
@@ -539,6 +638,12 @@ class WebSocketServer:
539
638
  await self._handle_session_end(websocket, data, client_info)
540
639
  return
541
640
 
641
+ if msg_type == "telemetry":
642
+ # The raw message goes along: the frame byte cap is on what was
643
+ # actually sent, and re-serializing `data` would not reproduce it.
644
+ await self._handle_telemetry(websocket, data, message, client_info)
645
+ return
646
+
542
647
  if "user_trigger" in data:
543
648
  await self._handle_trigger(websocket, data, client_info)
544
649
  return
@@ -839,6 +944,125 @@ class WebSocketServer:
839
944
  is_complete=True,
840
945
  )
841
946
 
947
+ async def _handle_telemetry(
948
+ self,
949
+ websocket: WebSocketServerProtocol,
950
+ data: dict,
951
+ raw_message: Any,
952
+ client_info: str,
953
+ ) -> None:
954
+ """handles a page-telemetry frame from the FE SDK.
955
+
956
+ The SDK is a relay here, not a store: a validated frame goes to the
957
+ host's on_telemetry callback and is forgotten. There is no Centive
958
+ telemetry endpoint, and nothing is buffered.
959
+
960
+ Telemetry frames carry a browser_session_id (a client tab, rolled over
961
+ on idle) rather than the chat session_id, so identity is enforced with
962
+ session_id=None: the frame's own user_id is still checked against the
963
+ connection's bound identity, but the session-ownership backstop, which
964
+ is specific to accumulated chat sessions, does not apply.
965
+ """
966
+ if self._config.ws_auth_mode == "open":
967
+ user_id = data.get("user_id")
968
+ # Stricter than the sibling handlers on purpose: they only
969
+ # f-string user_id into a session key, but this one crosses a
970
+ # public API boundary (on_telemetry is documented as user_id: str).
971
+ if not isinstance(user_id, str) or not user_id:
972
+ await websocket.send(json.dumps({
973
+ "status": "error",
974
+ "message": "missing user_id",
975
+ "error": "MISSING_FIELDS",
976
+ }))
977
+ return
978
+ else:
979
+ user_id = await self._enforce_frame_identity(websocket, data, None, client_info)
980
+ if user_id is None:
981
+ return
982
+
983
+ async def ack(accepted: int, dropped: int, reason: Optional[str] = None) -> None:
984
+ frame = {
985
+ "status": "ok",
986
+ "type": "telemetry_ack",
987
+ "accepted": accepted,
988
+ "dropped": dropped,
989
+ }
990
+ if reason is not None:
991
+ frame["reason"] = reason
992
+ await websocket.send(json.dumps(frame))
993
+
994
+ events = data.get("events")
995
+ # A rejected frame may not even hold a list here, and 0 is the honest
996
+ # count when there is nothing countable to report as dropped.
997
+ #
998
+ # _validate_telemetry checks frame_too_large before checking that
999
+ # events is a list, so an oversized frame with a missing/malformed
1000
+ # events field reports accepted: 0, dropped: 0 here — the frame was
1001
+ # still dropped in full, this 0 is just an uncounted one, not a bug.
1002
+ event_count = len(events) if isinstance(events, list) else 0
1003
+
1004
+ failure = self._validate_telemetry(data, raw_message)
1005
+ if failure is not None:
1006
+ kind, code = failure
1007
+ if kind == "cap":
1008
+ # Caps are flow control, so they leave through the channel the
1009
+ # producer actually parses. An error frame would be invisible.
1010
+ await ack(0, event_count, code)
1011
+ else:
1012
+ await websocket.send(json.dumps({
1013
+ "status": "error",
1014
+ "message": "invalid telemetry payload",
1015
+ "error": code,
1016
+ }))
1017
+ return
1018
+
1019
+ if self._on_telemetry is None:
1020
+ # Acked rather than errored: the frame was fine, this deployment
1021
+ # simply has nowhere to put it. The producer does not retry on a
1022
+ # non-zero `dropped`, which is what we want.
1023
+ await ack(0, event_count, "no_telemetry_handler")
1024
+ return
1025
+
1026
+ try:
1027
+ # The whole validated frame goes over, not just the events: `schema`
1028
+ # and `sdk` matter to anyone storing them, and `user_id` is the
1029
+ # trusted identity, not the frame's (which _enforce_frame_identity
1030
+ # has already proven absent or equal).
1031
+ #
1032
+ # ponytail: a blocking sync callback stalls the event loop; hosts
1033
+ # doing real work should hand off to a queue or an executor.
1034
+ result = self._on_telemetry(user_id, data)
1035
+ if inspect.isawaitable(result):
1036
+ await result
1037
+ except Exception as e:
1038
+ # A host callback must never take the connection down with it, and
1039
+ # the client has no ack-based retry, so the frame is simply lost.
1040
+ #
1041
+ # error is the exception type only, never str(e): the callback got
1042
+ # the whole frame, so its exception text is host-authored over
1043
+ # user-navigation data (page.path, page.title, properties) that
1044
+ # safe_log's redaction does not cover. Do not "improve" this back
1045
+ # to str(e).
1046
+ safe_log(
1047
+ self._logger,
1048
+ "error",
1049
+ "on_telemetry callback failed, dropping frame",
1050
+ {"client": client_info, "user_id": user_id, "error": type(e).__name__},
1051
+ )
1052
+ await ack(0, event_count, "handler_error")
1053
+ return
1054
+
1055
+ # Counts only: page paths, titles and properties are navigation data
1056
+ # that safe_log's redaction does not cover.
1057
+ safe_log(
1058
+ self._logger,
1059
+ "debug",
1060
+ "telemetry received",
1061
+ {"client": client_info, "user_id": user_id, "event_count": event_count},
1062
+ )
1063
+
1064
+ await ack(event_count, 0)
1065
+
842
1066
  async def _save_disconnected_sessions(
843
1067
  self,
844
1068
  session_keys: Set[str],
@@ -110,6 +110,42 @@ def session_end_event_data():
110
110
  }
111
111
 
112
112
 
113
+ @pytest.fixture
114
+ def telemetry_event_data():
115
+ """A page-telemetry frame exactly as @centive/aria-sdk 1.1.0-dev.4 sends it."""
116
+ return {
117
+ "type": "telemetry",
118
+ "schema": 1,
119
+ "user_id": "user_123",
120
+ "sdk": "aria-sdk/1.1.0-dev.4",
121
+ "events": [
122
+ {
123
+ "client_event_id": "3f8b0c62-1f3a-4a1e-9c1e-0f2d7a5b6c11",
124
+ "event_type": "page_view",
125
+ "occurred_at": "2026-09-01T10:00:00.000Z",
126
+ "browser_session_id": "bs_9a1c4e7b2d",
127
+ "page": {
128
+ "path": "/dashboard",
129
+ "pattern": "/dashboard",
130
+ "title": "Dashboard",
131
+ },
132
+ "duration_ms": None,
133
+ "detection": "navigation-api",
134
+ },
135
+ {
136
+ "client_event_id": "5d2a91f4-7c60-4b93-8a2f-1e4c8d0b3a77",
137
+ "event_type": "page_leave",
138
+ "occurred_at": "2026-09-01T10:00:42.000Z",
139
+ "browser_session_id": "bs_9a1c4e7b2d",
140
+ "page": {"path": "/dashboard", "referrer_path": "/"},
141
+ "duration_ms": 42000,
142
+ "detection": "history",
143
+ "properties": {"_dropped": 3},
144
+ },
145
+ ],
146
+ }
147
+
148
+
113
149
  @pytest.fixture
114
150
  def pause_status_not_paused_response():
115
151
  return {