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.
- {centive_sdk-2.1.0.dev2 → centive_sdk-2.1.0.dev3}/CHANGELOG.md +41 -0
- {centive_sdk-2.1.0.dev2 → centive_sdk-2.1.0.dev3}/PKG-INFO +85 -1
- {centive_sdk-2.1.0.dev2 → centive_sdk-2.1.0.dev3}/README.md +84 -0
- {centive_sdk-2.1.0.dev2 → centive_sdk-2.1.0.dev3}/pyproject.toml +1 -1
- {centive_sdk-2.1.0.dev2 → centive_sdk-2.1.0.dev3}/src/centive_sdk/async_client.py +9 -0
- {centive_sdk-2.1.0.dev2 → centive_sdk-2.1.0.dev3}/src/centive_sdk/config.py +10 -0
- {centive_sdk-2.1.0.dev2 → centive_sdk-2.1.0.dev3}/src/centive_sdk/resources/websocket_server.py +225 -1
- {centive_sdk-2.1.0.dev2 → centive_sdk-2.1.0.dev3}/tests/conftest.py +36 -0
- {centive_sdk-2.1.0.dev2 → centive_sdk-2.1.0.dev3}/tests/test_websocket_security.py +233 -0
- centive_sdk-2.1.0.dev3/tests/test_websocket_server.py +969 -0
- centive_sdk-2.1.0.dev2/tests/test_websocket_server.py +0 -419
- {centive_sdk-2.1.0.dev2 → centive_sdk-2.1.0.dev3}/.gitignore +0 -0
- {centive_sdk-2.1.0.dev2 → centive_sdk-2.1.0.dev3}/LICENSE +0 -0
- {centive_sdk-2.1.0.dev2 → centive_sdk-2.1.0.dev3}/blackbox/README.md +0 -0
- {centive_sdk-2.1.0.dev2 → centive_sdk-2.1.0.dev3}/blackbox/customer_app.py +0 -0
- {centive_sdk-2.1.0.dev2 → centive_sdk-2.1.0.dev3}/blackbox/harness.py +0 -0
- {centive_sdk-2.1.0.dev2 → centive_sdk-2.1.0.dev3}/blackbox/mock_centive_api.py +0 -0
- {centive_sdk-2.1.0.dev2 → centive_sdk-2.1.0.dev3}/blackbox/test_blackbox.py +0 -0
- {centive_sdk-2.1.0.dev2 → centive_sdk-2.1.0.dev3}/examples/async_example.py +0 -0
- {centive_sdk-2.1.0.dev2 → centive_sdk-2.1.0.dev3}/examples/fastapi_integration.py +0 -0
- {centive_sdk-2.1.0.dev2 → centive_sdk-2.1.0.dev3}/examples/sync_example.py +0 -0
- {centive_sdk-2.1.0.dev2 → centive_sdk-2.1.0.dev3}/examples/websocket_example.py +0 -0
- {centive_sdk-2.1.0.dev2 → centive_sdk-2.1.0.dev3}/src/centive_sdk/__init__.py +0 -0
- {centive_sdk-2.1.0.dev2 → centive_sdk-2.1.0.dev3}/src/centive_sdk/_http.py +0 -0
- {centive_sdk-2.1.0.dev2 → centive_sdk-2.1.0.dev3}/src/centive_sdk/_logging.py +0 -0
- {centive_sdk-2.1.0.dev2 → centive_sdk-2.1.0.dev3}/src/centive_sdk/_retry.py +0 -0
- {centive_sdk-2.1.0.dev2 → centive_sdk-2.1.0.dev3}/src/centive_sdk/client.py +0 -0
- {centive_sdk-2.1.0.dev2 → centive_sdk-2.1.0.dev3}/src/centive_sdk/exceptions.py +0 -0
- {centive_sdk-2.1.0.dev2 → centive_sdk-2.1.0.dev3}/src/centive_sdk/models/__init__.py +0 -0
- {centive_sdk-2.1.0.dev2 → centive_sdk-2.1.0.dev3}/src/centive_sdk/models/requests.py +0 -0
- {centive_sdk-2.1.0.dev2 → centive_sdk-2.1.0.dev3}/src/centive_sdk/models/responses.py +0 -0
- {centive_sdk-2.1.0.dev2 → centive_sdk-2.1.0.dev3}/src/centive_sdk/py.typed +0 -0
- {centive_sdk-2.1.0.dev2 → centive_sdk-2.1.0.dev3}/src/centive_sdk/resources/__init__.py +0 -0
- {centive_sdk-2.1.0.dev2 → centive_sdk-2.1.0.dev3}/src/centive_sdk/resources/async_sessions.py +0 -0
- {centive_sdk-2.1.0.dev2 → centive_sdk-2.1.0.dev3}/src/centive_sdk/resources/message_accumulator.py +0 -0
- {centive_sdk-2.1.0.dev2 → centive_sdk-2.1.0.dev3}/src/centive_sdk/resources/sessions.py +0 -0
- {centive_sdk-2.1.0.dev2 → centive_sdk-2.1.0.dev3}/tests/__init__.py +0 -0
- {centive_sdk-2.1.0.dev2 → centive_sdk-2.1.0.dev3}/tests/test_client_async.py +0 -0
- {centive_sdk-2.1.0.dev2 → centive_sdk-2.1.0.dev3}/tests/test_client_sync.py +0 -0
- {centive_sdk-2.1.0.dev2 → centive_sdk-2.1.0.dev3}/tests/test_config.py +0 -0
- {centive_sdk-2.1.0.dev2 → centive_sdk-2.1.0.dev3}/tests/test_error_mapping.py +0 -0
- {centive_sdk-2.1.0.dev2 → centive_sdk-2.1.0.dev3}/tests/test_logging_redaction.py +0 -0
- {centive_sdk-2.1.0.dev2 → centive_sdk-2.1.0.dev3}/tests/test_message_accumulator.py +0 -0
- {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.
|
|
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)
|
|
@@ -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.
|
{centive_sdk-2.1.0.dev2 → centive_sdk-2.1.0.dev3}/src/centive_sdk/resources/websocket_server.py
RENAMED
|
@@ -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 {
|