centive-sdk 2.1.0.dev2__tar.gz → 2.2.0.dev4__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.2.0.dev4}/CHANGELOG.md +86 -0
- {centive_sdk-2.1.0.dev2 → centive_sdk-2.2.0.dev4}/PKG-INFO +138 -1
- {centive_sdk-2.1.0.dev2 → centive_sdk-2.2.0.dev4}/README.md +137 -0
- {centive_sdk-2.1.0.dev2 → centive_sdk-2.2.0.dev4}/blackbox/customer_app.py +21 -0
- {centive_sdk-2.1.0.dev2 → centive_sdk-2.2.0.dev4}/blackbox/harness.py +26 -0
- {centive_sdk-2.1.0.dev2 → centive_sdk-2.2.0.dev4}/blackbox/mock_centive_api.py +39 -0
- centive_sdk-2.2.0.dev4/blackbox/test_blackbox_telemetry.py +250 -0
- {centive_sdk-2.1.0.dev2 → centive_sdk-2.2.0.dev4}/pyproject.toml +1 -1
- {centive_sdk-2.1.0.dev2 → centive_sdk-2.2.0.dev4}/src/centive_sdk/__init__.py +12 -0
- {centive_sdk-2.1.0.dev2 → centive_sdk-2.2.0.dev4}/src/centive_sdk/_logging.py +5 -0
- {centive_sdk-2.1.0.dev2 → centive_sdk-2.2.0.dev4}/src/centive_sdk/async_client.py +43 -0
- {centive_sdk-2.1.0.dev2 → centive_sdk-2.2.0.dev4}/src/centive_sdk/config.py +24 -0
- centive_sdk-2.2.0.dev4/src/centive_sdk/models/__init__.py +27 -0
- {centive_sdk-2.1.0.dev2 → centive_sdk-2.2.0.dev4}/src/centive_sdk/models/requests.py +78 -2
- {centive_sdk-2.1.0.dev2 → centive_sdk-2.2.0.dev4}/src/centive_sdk/models/responses.py +13 -0
- {centive_sdk-2.1.0.dev2 → centive_sdk-2.2.0.dev4}/src/centive_sdk/resources/async_sessions.py +39 -2
- centive_sdk-2.2.0.dev4/src/centive_sdk/resources/async_telemetry.py +65 -0
- centive_sdk-2.2.0.dev4/src/centive_sdk/resources/telemetry_batcher.py +341 -0
- {centive_sdk-2.1.0.dev2 → centive_sdk-2.2.0.dev4}/src/centive_sdk/resources/websocket_server.py +270 -1
- {centive_sdk-2.1.0.dev2 → centive_sdk-2.2.0.dev4}/tests/conftest.py +36 -0
- centive_sdk-2.2.0.dev4/tests/test_telemetry_batcher.py +267 -0
- {centive_sdk-2.1.0.dev2 → centive_sdk-2.2.0.dev4}/tests/test_websocket_security.py +233 -0
- centive_sdk-2.2.0.dev4/tests/test_websocket_server.py +987 -0
- centive_sdk-2.2.0.dev4/tests/test_websocket_telemetry.py +349 -0
- centive_sdk-2.1.0.dev2/src/centive_sdk/models/__init__.py +0 -10
- centive_sdk-2.1.0.dev2/tests/test_websocket_server.py +0 -419
- {centive_sdk-2.1.0.dev2 → centive_sdk-2.2.0.dev4}/.gitignore +0 -0
- {centive_sdk-2.1.0.dev2 → centive_sdk-2.2.0.dev4}/LICENSE +0 -0
- {centive_sdk-2.1.0.dev2 → centive_sdk-2.2.0.dev4}/blackbox/README.md +0 -0
- {centive_sdk-2.1.0.dev2 → centive_sdk-2.2.0.dev4}/blackbox/test_blackbox.py +0 -0
- {centive_sdk-2.1.0.dev2 → centive_sdk-2.2.0.dev4}/examples/async_example.py +0 -0
- {centive_sdk-2.1.0.dev2 → centive_sdk-2.2.0.dev4}/examples/fastapi_integration.py +0 -0
- {centive_sdk-2.1.0.dev2 → centive_sdk-2.2.0.dev4}/examples/sync_example.py +0 -0
- {centive_sdk-2.1.0.dev2 → centive_sdk-2.2.0.dev4}/examples/websocket_example.py +0 -0
- {centive_sdk-2.1.0.dev2 → centive_sdk-2.2.0.dev4}/src/centive_sdk/_http.py +0 -0
- {centive_sdk-2.1.0.dev2 → centive_sdk-2.2.0.dev4}/src/centive_sdk/_retry.py +0 -0
- {centive_sdk-2.1.0.dev2 → centive_sdk-2.2.0.dev4}/src/centive_sdk/client.py +0 -0
- {centive_sdk-2.1.0.dev2 → centive_sdk-2.2.0.dev4}/src/centive_sdk/exceptions.py +0 -0
- {centive_sdk-2.1.0.dev2 → centive_sdk-2.2.0.dev4}/src/centive_sdk/py.typed +0 -0
- {centive_sdk-2.1.0.dev2 → centive_sdk-2.2.0.dev4}/src/centive_sdk/resources/__init__.py +0 -0
- {centive_sdk-2.1.0.dev2 → centive_sdk-2.2.0.dev4}/src/centive_sdk/resources/message_accumulator.py +0 -0
- {centive_sdk-2.1.0.dev2 → centive_sdk-2.2.0.dev4}/src/centive_sdk/resources/sessions.py +0 -0
- {centive_sdk-2.1.0.dev2 → centive_sdk-2.2.0.dev4}/tests/__init__.py +0 -0
- {centive_sdk-2.1.0.dev2 → centive_sdk-2.2.0.dev4}/tests/test_client_async.py +0 -0
- {centive_sdk-2.1.0.dev2 → centive_sdk-2.2.0.dev4}/tests/test_client_sync.py +0 -0
- {centive_sdk-2.1.0.dev2 → centive_sdk-2.2.0.dev4}/tests/test_config.py +0 -0
- {centive_sdk-2.1.0.dev2 → centive_sdk-2.2.0.dev4}/tests/test_error_mapping.py +0 -0
- {centive_sdk-2.1.0.dev2 → centive_sdk-2.2.0.dev4}/tests/test_logging_redaction.py +0 -0
- {centive_sdk-2.1.0.dev2 → centive_sdk-2.2.0.dev4}/tests/test_message_accumulator.py +0 -0
- {centive_sdk-2.1.0.dev2 → centive_sdk-2.2.0.dev4}/tests/test_retry_logic.py +0 -0
|
@@ -2,6 +2,92 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project are documented in this file.
|
|
4
4
|
|
|
5
|
+
## v2.2.0
|
|
6
|
+
|
|
7
|
+
Unreleased (branch `feature/user-page-tracking`, merged with the v2.1.0
|
|
8
|
+
`on_telemetry` handler from dev; one unified telemetry handler feeds both the
|
|
9
|
+
Centive batcher and the host callback).
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- **Product events (page tracking).** A new websocket frame type,
|
|
14
|
+
`type: "telemetry"`, carries page views and page leaves from the browser SDK.
|
|
15
|
+
The server validates each frame (identity, size, per-connection rate), queues
|
|
16
|
+
the events and forwards them to `POST /anam/events` in batches (every 2 s or
|
|
17
|
+
50 events). Contract: CentiveAI `docs/product-events/CONTRACTS.md`.
|
|
18
|
+
- `client.telemetry.track(user_id, event_type, properties)` to emit
|
|
19
|
+
server-side events into the same pipe, `client.telemetry.flush()` and
|
|
20
|
+
`client.telemetry.stats()`.
|
|
21
|
+
- **Live Aria context relay.** When the `POST /anam/events` response carries
|
|
22
|
+
`context[]` (Centive believes the user has a live Aria session), the SDK
|
|
23
|
+
pushes each entry to that user's socket only as a `{"type": "context"}`
|
|
24
|
+
frame; the browser SDK injects it with `anamClient.addContext`. Counted in
|
|
25
|
+
`stats()` as `context_received` / `context_delivered`. Anam has no
|
|
26
|
+
server-side way to inform a running session, so this is the only path.
|
|
27
|
+
- `AsyncSessions.send_events(ProductEventBatchRequest)`.
|
|
28
|
+
- Models: `TelemetryEvent`, `TelemetryFrame`, `TelemetryUserBatch`,
|
|
29
|
+
`ProductEventBatchRequest`, `ProductEventIngestResponse`.
|
|
30
|
+
- Config: `events_path`, `telemetry_enabled`, `telemetry_flush_max_events`,
|
|
31
|
+
`telemetry_flush_interval_seconds`, `telemetry_max_queue_events`,
|
|
32
|
+
`telemetry_max_events_per_frame`, `telemetry_max_frame_bytes`,
|
|
33
|
+
`telemetry_rate_per_connection_per_minute`.
|
|
34
|
+
|
|
35
|
+
### Behavior
|
|
36
|
+
|
|
37
|
+
- The telemetry queue has its own circuit breaker, so a failing events
|
|
38
|
+
endpoint cannot stop transcript saves (and vice versa).
|
|
39
|
+
- The queue is bounded (`telemetry_max_queue_events`, default 5000); when full
|
|
40
|
+
the oldest events are dropped and counted in `stats()`. The websocket handler
|
|
41
|
+
never blocks on telemetry.
|
|
42
|
+
- A `202` from the API is final; its `rejections` (for example
|
|
43
|
+
`user_not_mapped_to_account`) are counted, never retried. `429`/`5xx`/network
|
|
44
|
+
errors re-queue the batch; `400`/`401`/`413`/`422` drop it.
|
|
45
|
+
- `413 Payload Too Large` is now mapped to `ValidationError` (previously
|
|
46
|
+
`ServerError`) for every request, since retrying the same body cannot help.
|
|
47
|
+
- Log redaction now masks `page_path`, `page_title`, `path`, `title` and
|
|
48
|
+
`referrer_path`.
|
|
49
|
+
|
|
50
|
+
## v2.1.0
|
|
51
|
+
|
|
52
|
+
Unreleased
|
|
53
|
+
|
|
54
|
+
### Added
|
|
55
|
+
|
|
56
|
+
- **Page-telemetry frames from the FE SDK are now handled.**
|
|
57
|
+
`@centive/aria-sdk` 1.1.0+ sends `{"type": "telemetry", ...}` page-view and
|
|
58
|
+
page-leave events over the existing Aria websocket. The server previously had
|
|
59
|
+
no branch for them, so every event fell through to the generic
|
|
60
|
+
`"message received"` reply and was discarded.
|
|
61
|
+
- **`on_telemetry` client option.** Pass a callable to `AsyncCentiveClient` —
|
|
62
|
+
`on_telemetry(user_id: str, frame: dict)`, sync or async, with the events at
|
|
63
|
+
`frame["events"]` and the producer's `frame["schema"]` / `frame["sdk"]`
|
|
64
|
+
alongside them — to receive those events. The SDK relays and forgets:
|
|
65
|
+
nothing is stored and nothing is sent to the Centive API. Without a callback
|
|
66
|
+
the frame is acknowledged with `accepted: 0` and dropped.
|
|
67
|
+
- **Events are attributed to the connection's bound user**, not to the frame's
|
|
68
|
+
`user_id`. A forged `user_id` is rejected with `IDENTITY_MISMATCH`, as on
|
|
69
|
+
every other frame type. Telemetry carries a `browser_session_id` (a client
|
|
70
|
+
tab) rather than a chat `session_id`, so no session is created or looked up.
|
|
71
|
+
- **The producer's caps are mirrored server-side**:
|
|
72
|
+
`max_telemetry_events_per_frame` (100), `max_telemetry_frame_bytes` (65536)
|
|
73
|
+
and `max_telemetry_properties_bytes` (4096). The transport cap
|
|
74
|
+
(`ws_max_size`, 1 MiB) is far too loose to catch a buggy or hostile client.
|
|
75
|
+
A frame over a cap is **acknowledged, not errored** —
|
|
76
|
+
`{"type": "telemetry_ack", "accepted": 0, "dropped": N, "reason": ...}` with
|
|
77
|
+
`reason` one of `frame_too_large`, `too_many_events`, `properties_too_large`
|
|
78
|
+
— because the frontend parses `telemetry_ack` and ignores error frames, so a
|
|
79
|
+
cap breach reported as an error would be invisible to it.
|
|
80
|
+
- A **malformed** frame still takes the ordinary error channel:
|
|
81
|
+
`INVALID_TELEMETRY_FORMAT` for missing or wrong-typed required fields.
|
|
82
|
+
- **The schema version is forwarded, not gated on.** `frame["schema"]` is
|
|
83
|
+
checked for shape and passed through, so a future producer version whose
|
|
84
|
+
events are still well-formed keeps working against this SDK rather than
|
|
85
|
+
requiring a lockstep backend deploy.
|
|
86
|
+
- `event_type` is treated as an open set: an event kind the server does not
|
|
87
|
+
recognize is forwarded, not rejected.
|
|
88
|
+
- A callback that raises cannot close the connection. The frame is acked as
|
|
89
|
+
dropped with `reason: "handler_error"` and the error goes to the server log.
|
|
90
|
+
|
|
5
91
|
## v2.0.0
|
|
6
92
|
|
|
7
93
|
Released: 2026-08-11
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: centive-sdk
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.2.0.dev4
|
|
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
|
|
@@ -194,6 +195,11 @@ an expired connection token.
|
|
|
194
195
|
| `ws_host` | `str` | `0.0.0.0` | websocket bind address (`127.0.0.1` behind a proxy) |
|
|
195
196
|
| `ws_port` | `int` | `8765` | websocket server port |
|
|
196
197
|
| `ws_auth_mode` | `"token" \| "open"` | `"token"` | connection identity binding (see Security) |
|
|
198
|
+
| `telemetry_enabled` | `bool` | `True` | accept `telemetry` frames and forward page events |
|
|
199
|
+
| `telemetry_flush_interval_seconds` | `float` | `2.0` | max time an event waits before being sent |
|
|
200
|
+
| `telemetry_flush_max_events` | `int` | `50` | send when this many events are queued |
|
|
201
|
+
| `telemetry_max_queue_events` | `int` | `5000` | bounded queue; oldest dropped when full |
|
|
202
|
+
| `telemetry_rate_per_connection_per_minute` | `int` | `120` | telemetry frames accepted per socket per minute |
|
|
197
203
|
| `ws_token_ttl_seconds`| `float` | `3600.0` | how long a connection token stays valid |
|
|
198
204
|
| `ws_allowed_origins` | `list[str]` | `None` | allow-list of Origin headers for WS handshakes |
|
|
199
205
|
| `ws_handshake_secret` | `str` | `None` | static shared secret (legacy `"open"` mode only) |
|
|
@@ -205,10 +211,14 @@ an expired connection token.
|
|
|
205
211
|
| `max_message_content_chars` | `int` | `32768` | longest single message accepted |
|
|
206
212
|
| `max_session_id_chars` | `int` | `200` | longest `session_id` accepted |
|
|
207
213
|
| `max_connections_per_user` | `int` | `5` | concurrent websocket connections per user |
|
|
214
|
+
| `max_telemetry_events_per_frame` | `int` | `100` | events accepted per telemetry frame |
|
|
215
|
+
| `max_telemetry_frame_bytes` | `int` | `65536` | largest telemetry frame accepted (UTF-8 bytes) |
|
|
216
|
+
| `max_telemetry_properties_bytes` | `int` | `4096` | serialized `properties` per telemetry event |
|
|
208
217
|
| `pause_check_fail_open` | `bool` | `True` | proceed as active if the pause check fails; `False` withholds access |
|
|
209
218
|
| `circuit_breaker_threshold` | `int` | `5` | failures before circuit opens |
|
|
210
219
|
| `circuit_breaker_recovery_seconds` | `float` | `60.0` | time before circuit recovery attempt |
|
|
211
220
|
| `logger` | `Optional[Any]` | `None` | custom logger instance for debugging |
|
|
221
|
+
| `on_telemetry` | `Optional[Callable]` | `None` | host sink for page-telemetry frames (see Page Telemetry) |
|
|
212
222
|
|
|
213
223
|
### Example with Custom Configuration
|
|
214
224
|
|
|
@@ -513,6 +523,133 @@ client = AsyncCentiveClient(
|
|
|
513
523
|
|
|
514
524
|
For details on WebSocket event formats, see `websocket_messages.md`.
|
|
515
525
|
|
|
526
|
+
## Page Tracking (Product Events)
|
|
527
|
+
|
|
528
|
+
From v2.2.0 the websocket server also accepts `type: "telemetry"` frames from
|
|
529
|
+
the browser SDK. These carry page views and page leaves for the connected end
|
|
530
|
+
user. The SDK validates each frame, queues the events, and forwards them to
|
|
531
|
+
Centive in batches (every 2 seconds or 50 events, whichever comes first).
|
|
532
|
+
|
|
533
|
+
Nothing is required from your code beyond what you already do for the avatar:
|
|
534
|
+
`initialize_websocket()` starts the queue and `aclose()` flushes it.
|
|
535
|
+
|
|
536
|
+
```python
|
|
537
|
+
# Optional: emit your own server-side events into the same pipe.
|
|
538
|
+
client.telemetry.track(user_id, "invoice_paid", {"amount": 120})
|
|
539
|
+
|
|
540
|
+
# Optional: inspect the queue.
|
|
541
|
+
client.telemetry.stats()
|
|
542
|
+
# {'enqueued': 412, 'sent': 410, 'accepted': 398, 'rejected': 12,
|
|
543
|
+
# 'rejections_by_reason': {'user_not_mapped_to_account': 12}, 'queue_depth': 2, ...}
|
|
544
|
+
```
|
|
545
|
+
|
|
546
|
+
Context back to Aria: when a user has a live Aria session, Centive answers the
|
|
547
|
+
events call with short context lines ("The user just opened the Invoices
|
|
548
|
+
page."). The SDK pushes each one to that user's browser as a `context` frame
|
|
549
|
+
and the browser SDK hands it to the avatar with `anamClient.addContext`. This
|
|
550
|
+
is why page tracking exists: Aria knows where the user is while they talk.
|
|
551
|
+
|
|
552
|
+
What Centive keeps: only events for end users that `tool_mapping` linked to an
|
|
553
|
+
account in your organization. Events for other users come back in the `202`
|
|
554
|
+
response as `rejected` with reason `user_not_mapped_to_account`; they are
|
|
555
|
+
counted in `stats()` and never retried. If that number is high, the
|
|
556
|
+
`company_name` you pass to `tool_mapping` does not match the account names in
|
|
557
|
+
Centive.
|
|
558
|
+
|
|
559
|
+
Safety properties:
|
|
560
|
+
|
|
561
|
+
- The browser cannot spoof identity: the frame's `user_id` must match the user
|
|
562
|
+
bound to the socket, exactly like transcript frames.
|
|
563
|
+
- Frames over 100 events or 64 KB, and more than 120 frames per minute per
|
|
564
|
+
connection, are dropped with a reasoned ack. The socket is never closed for
|
|
565
|
+
a telemetry violation.
|
|
566
|
+
- The queue is bounded (`telemetry_max_queue_events`, default 5000). When full,
|
|
567
|
+
the oldest events are dropped and counted. The websocket handler never blocks.
|
|
568
|
+
- Telemetry has its own circuit breaker; a failing events endpoint cannot stop
|
|
569
|
+
transcript saves.
|
|
570
|
+
- Set `telemetry_enabled=False` to turn the feature off entirely.
|
|
571
|
+
|
|
572
|
+
Wire contract: `docs/product-events/CONTRACTS.md` in the CentiveAI repository.
|
|
573
|
+
|
|
574
|
+
## Page Telemetry
|
|
575
|
+
|
|
576
|
+
The FE SDK (`@centive/aria-sdk` 1.1.0+) sends page-view and page-leave events over
|
|
577
|
+
the same websocket as the chat traffic. The BE SDK validates them, attributes them
|
|
578
|
+
to the connection's authenticated user, and hands them to a callback you supply.
|
|
579
|
+
**It stores nothing and forwards nothing to the Centive API** — where the events go
|
|
580
|
+
is entirely your decision.
|
|
581
|
+
|
|
582
|
+
Your callback receives two arguments: `user_id`, the identity bound to the websocket
|
|
583
|
+
connection (never a value the browser claimed), and `frame`, the validated frame.
|
|
584
|
+
The events are at `frame["events"]`; `frame["schema"]` and `frame["sdk"]` carry the
|
|
585
|
+
producer's schema version and SDK version, which are worth storing alongside them.
|
|
586
|
+
|
|
587
|
+
```python
|
|
588
|
+
async def record_page_events(user_id: str, frame: dict) -> None:
|
|
589
|
+
await analytics.insert_many(
|
|
590
|
+
{**event, "user_id": user_id, "sdk": frame.get("sdk")}
|
|
591
|
+
for event in frame["events"]
|
|
592
|
+
)
|
|
593
|
+
|
|
594
|
+
client = AsyncCentiveClient(
|
|
595
|
+
api_key="sk_live_your_api_key",
|
|
596
|
+
on_telemetry=record_page_events, # sync callables work too
|
|
597
|
+
)
|
|
598
|
+
```
|
|
599
|
+
|
|
600
|
+
`**event` is spread *first*, never last: events are forwarded verbatim with
|
|
601
|
+
no key filtering, so a client-supplied `user_id` inside an event would
|
|
602
|
+
otherwise overwrite the identity the server already verified. Keep the
|
|
603
|
+
trusted `user_id` and `sdk` after the spread.
|
|
604
|
+
|
|
605
|
+
Each event carries `client_event_id`, `event_type`, `occurred_at`,
|
|
606
|
+
`browser_session_id`, `detection`, and usually a `page` object
|
|
607
|
+
(`path`, `pattern`, `title`, `referrer_path`) plus `duration_ms` on `page_leave`.
|
|
608
|
+
`event_type` is an open set — new client-side event kinds reach your callback
|
|
609
|
+
rather than being rejected, so switch on the values you know and ignore the rest.
|
|
610
|
+
Each of `client_event_id`, `event_type`, `occurred_at` and `browser_session_id`
|
|
611
|
+
is capped at 256 characters; over that, the whole frame is rejected with
|
|
612
|
+
`INVALID_TELEMETRY_FORMAT` rather than truncated, so keep custom `event_type`
|
|
613
|
+
values well under that.
|
|
614
|
+
|
|
615
|
+
`telemetry_ack` carries one of five `reason` values whenever `accepted` is
|
|
616
|
+
less than the event count:
|
|
617
|
+
|
|
618
|
+
| `reason` | Meaning |
|
|
619
|
+
| --- | --- |
|
|
620
|
+
| `frame_too_large` | frame exceeded the 64 KB wire-size cap |
|
|
621
|
+
| `too_many_events` | frame exceeded the 100-events-per-frame cap |
|
|
622
|
+
| `properties_too_large` | one event's `properties` exceeded the 4 KB cap |
|
|
623
|
+
| `no_telemetry_handler` | no `on_telemetry` callback is configured |
|
|
624
|
+
| `handler_error` | the callback raised; see your logger for the exception |
|
|
625
|
+
|
|
626
|
+
Behavior worth knowing:
|
|
627
|
+
|
|
628
|
+
- **Without `on_telemetry`, events are dropped.** The frame is still acknowledged,
|
|
629
|
+
with `accepted: 0` and `reason: "no_telemetry_handler"`, so the frontend never
|
|
630
|
+
stalls waiting on a sink that does not exist.
|
|
631
|
+
- **A callback that raises cannot break the connection.** The frame is acked as
|
|
632
|
+
dropped (`reason: "handler_error"`) and the exception goes to your logger.
|
|
633
|
+
- **A sync callback runs on the event loop.** If yours does real work — a database
|
|
634
|
+
write, an HTTP call — make it `async`, or hand off to a queue.
|
|
635
|
+
- **The producer's own caps are enforced here too** (100 events per frame, 64 KB
|
|
636
|
+
per frame, 4 KB of `properties` per event). An over-cap frame never reaches your
|
|
637
|
+
callback; it is acknowledged with `accepted: 0` and a `reason` of
|
|
638
|
+
`too_many_events`, `frame_too_large` or `properties_too_large`. A *malformed*
|
|
639
|
+
frame gets the ordinary error response instead (`INVALID_TELEMETRY_FORMAT`).
|
|
640
|
+
- **A newer `schema` is not an error.** The SDK does not gate on the version, so
|
|
641
|
+
a future FE SDK release keeps working without a backend deploy. Branch on
|
|
642
|
+
`frame["schema"]` yourself if your storage ever needs to.
|
|
643
|
+
- **`properties._dropped`** is a counter the browser SDK reports when its own buffer
|
|
644
|
+
had to evict events. It is relayed to your callback verbatim; the SDK never acts
|
|
645
|
+
on it.
|
|
646
|
+
- **`properties._properties_dropped`** replaces the entire `properties` object with
|
|
647
|
+
`{"_properties_dropped": true}` when the browser's own properties blob exceeded
|
|
648
|
+
4 KB before it was ever sent. Unlike `_dropped`, this is not a count alongside
|
|
649
|
+
intact data — it means the original properties are gone entirely, not truncated.
|
|
650
|
+
- **There is no rate limiting.** The frontend understands a
|
|
651
|
+
`reason: "rate_limited"` ack, but the SDK never sends one today.
|
|
652
|
+
|
|
516
653
|
## Advanced Usage
|
|
517
654
|
|
|
518
655
|
### 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
|
|
@@ -161,6 +162,11 @@ an expired connection token.
|
|
|
161
162
|
| `ws_host` | `str` | `0.0.0.0` | websocket bind address (`127.0.0.1` behind a proxy) |
|
|
162
163
|
| `ws_port` | `int` | `8765` | websocket server port |
|
|
163
164
|
| `ws_auth_mode` | `"token" \| "open"` | `"token"` | connection identity binding (see Security) |
|
|
165
|
+
| `telemetry_enabled` | `bool` | `True` | accept `telemetry` frames and forward page events |
|
|
166
|
+
| `telemetry_flush_interval_seconds` | `float` | `2.0` | max time an event waits before being sent |
|
|
167
|
+
| `telemetry_flush_max_events` | `int` | `50` | send when this many events are queued |
|
|
168
|
+
| `telemetry_max_queue_events` | `int` | `5000` | bounded queue; oldest dropped when full |
|
|
169
|
+
| `telemetry_rate_per_connection_per_minute` | `int` | `120` | telemetry frames accepted per socket per minute |
|
|
164
170
|
| `ws_token_ttl_seconds`| `float` | `3600.0` | how long a connection token stays valid |
|
|
165
171
|
| `ws_allowed_origins` | `list[str]` | `None` | allow-list of Origin headers for WS handshakes |
|
|
166
172
|
| `ws_handshake_secret` | `str` | `None` | static shared secret (legacy `"open"` mode only) |
|
|
@@ -172,10 +178,14 @@ an expired connection token.
|
|
|
172
178
|
| `max_message_content_chars` | `int` | `32768` | longest single message accepted |
|
|
173
179
|
| `max_session_id_chars` | `int` | `200` | longest `session_id` accepted |
|
|
174
180
|
| `max_connections_per_user` | `int` | `5` | concurrent websocket connections per user |
|
|
181
|
+
| `max_telemetry_events_per_frame` | `int` | `100` | events accepted per telemetry frame |
|
|
182
|
+
| `max_telemetry_frame_bytes` | `int` | `65536` | largest telemetry frame accepted (UTF-8 bytes) |
|
|
183
|
+
| `max_telemetry_properties_bytes` | `int` | `4096` | serialized `properties` per telemetry event |
|
|
175
184
|
| `pause_check_fail_open` | `bool` | `True` | proceed as active if the pause check fails; `False` withholds access |
|
|
176
185
|
| `circuit_breaker_threshold` | `int` | `5` | failures before circuit opens |
|
|
177
186
|
| `circuit_breaker_recovery_seconds` | `float` | `60.0` | time before circuit recovery attempt |
|
|
178
187
|
| `logger` | `Optional[Any]` | `None` | custom logger instance for debugging |
|
|
188
|
+
| `on_telemetry` | `Optional[Callable]` | `None` | host sink for page-telemetry frames (see Page Telemetry) |
|
|
179
189
|
|
|
180
190
|
### Example with Custom Configuration
|
|
181
191
|
|
|
@@ -480,6 +490,133 @@ client = AsyncCentiveClient(
|
|
|
480
490
|
|
|
481
491
|
For details on WebSocket event formats, see `websocket_messages.md`.
|
|
482
492
|
|
|
493
|
+
## Page Tracking (Product Events)
|
|
494
|
+
|
|
495
|
+
From v2.2.0 the websocket server also accepts `type: "telemetry"` frames from
|
|
496
|
+
the browser SDK. These carry page views and page leaves for the connected end
|
|
497
|
+
user. The SDK validates each frame, queues the events, and forwards them to
|
|
498
|
+
Centive in batches (every 2 seconds or 50 events, whichever comes first).
|
|
499
|
+
|
|
500
|
+
Nothing is required from your code beyond what you already do for the avatar:
|
|
501
|
+
`initialize_websocket()` starts the queue and `aclose()` flushes it.
|
|
502
|
+
|
|
503
|
+
```python
|
|
504
|
+
# Optional: emit your own server-side events into the same pipe.
|
|
505
|
+
client.telemetry.track(user_id, "invoice_paid", {"amount": 120})
|
|
506
|
+
|
|
507
|
+
# Optional: inspect the queue.
|
|
508
|
+
client.telemetry.stats()
|
|
509
|
+
# {'enqueued': 412, 'sent': 410, 'accepted': 398, 'rejected': 12,
|
|
510
|
+
# 'rejections_by_reason': {'user_not_mapped_to_account': 12}, 'queue_depth': 2, ...}
|
|
511
|
+
```
|
|
512
|
+
|
|
513
|
+
Context back to Aria: when a user has a live Aria session, Centive answers the
|
|
514
|
+
events call with short context lines ("The user just opened the Invoices
|
|
515
|
+
page."). The SDK pushes each one to that user's browser as a `context` frame
|
|
516
|
+
and the browser SDK hands it to the avatar with `anamClient.addContext`. This
|
|
517
|
+
is why page tracking exists: Aria knows where the user is while they talk.
|
|
518
|
+
|
|
519
|
+
What Centive keeps: only events for end users that `tool_mapping` linked to an
|
|
520
|
+
account in your organization. Events for other users come back in the `202`
|
|
521
|
+
response as `rejected` with reason `user_not_mapped_to_account`; they are
|
|
522
|
+
counted in `stats()` and never retried. If that number is high, the
|
|
523
|
+
`company_name` you pass to `tool_mapping` does not match the account names in
|
|
524
|
+
Centive.
|
|
525
|
+
|
|
526
|
+
Safety properties:
|
|
527
|
+
|
|
528
|
+
- The browser cannot spoof identity: the frame's `user_id` must match the user
|
|
529
|
+
bound to the socket, exactly like transcript frames.
|
|
530
|
+
- Frames over 100 events or 64 KB, and more than 120 frames per minute per
|
|
531
|
+
connection, are dropped with a reasoned ack. The socket is never closed for
|
|
532
|
+
a telemetry violation.
|
|
533
|
+
- The queue is bounded (`telemetry_max_queue_events`, default 5000). When full,
|
|
534
|
+
the oldest events are dropped and counted. The websocket handler never blocks.
|
|
535
|
+
- Telemetry has its own circuit breaker; a failing events endpoint cannot stop
|
|
536
|
+
transcript saves.
|
|
537
|
+
- Set `telemetry_enabled=False` to turn the feature off entirely.
|
|
538
|
+
|
|
539
|
+
Wire contract: `docs/product-events/CONTRACTS.md` in the CentiveAI repository.
|
|
540
|
+
|
|
541
|
+
## Page Telemetry
|
|
542
|
+
|
|
543
|
+
The FE SDK (`@centive/aria-sdk` 1.1.0+) sends page-view and page-leave events over
|
|
544
|
+
the same websocket as the chat traffic. The BE SDK validates them, attributes them
|
|
545
|
+
to the connection's authenticated user, and hands them to a callback you supply.
|
|
546
|
+
**It stores nothing and forwards nothing to the Centive API** — where the events go
|
|
547
|
+
is entirely your decision.
|
|
548
|
+
|
|
549
|
+
Your callback receives two arguments: `user_id`, the identity bound to the websocket
|
|
550
|
+
connection (never a value the browser claimed), and `frame`, the validated frame.
|
|
551
|
+
The events are at `frame["events"]`; `frame["schema"]` and `frame["sdk"]` carry the
|
|
552
|
+
producer's schema version and SDK version, which are worth storing alongside them.
|
|
553
|
+
|
|
554
|
+
```python
|
|
555
|
+
async def record_page_events(user_id: str, frame: dict) -> None:
|
|
556
|
+
await analytics.insert_many(
|
|
557
|
+
{**event, "user_id": user_id, "sdk": frame.get("sdk")}
|
|
558
|
+
for event in frame["events"]
|
|
559
|
+
)
|
|
560
|
+
|
|
561
|
+
client = AsyncCentiveClient(
|
|
562
|
+
api_key="sk_live_your_api_key",
|
|
563
|
+
on_telemetry=record_page_events, # sync callables work too
|
|
564
|
+
)
|
|
565
|
+
```
|
|
566
|
+
|
|
567
|
+
`**event` is spread *first*, never last: events are forwarded verbatim with
|
|
568
|
+
no key filtering, so a client-supplied `user_id` inside an event would
|
|
569
|
+
otherwise overwrite the identity the server already verified. Keep the
|
|
570
|
+
trusted `user_id` and `sdk` after the spread.
|
|
571
|
+
|
|
572
|
+
Each event carries `client_event_id`, `event_type`, `occurred_at`,
|
|
573
|
+
`browser_session_id`, `detection`, and usually a `page` object
|
|
574
|
+
(`path`, `pattern`, `title`, `referrer_path`) plus `duration_ms` on `page_leave`.
|
|
575
|
+
`event_type` is an open set — new client-side event kinds reach your callback
|
|
576
|
+
rather than being rejected, so switch on the values you know and ignore the rest.
|
|
577
|
+
Each of `client_event_id`, `event_type`, `occurred_at` and `browser_session_id`
|
|
578
|
+
is capped at 256 characters; over that, the whole frame is rejected with
|
|
579
|
+
`INVALID_TELEMETRY_FORMAT` rather than truncated, so keep custom `event_type`
|
|
580
|
+
values well under that.
|
|
581
|
+
|
|
582
|
+
`telemetry_ack` carries one of five `reason` values whenever `accepted` is
|
|
583
|
+
less than the event count:
|
|
584
|
+
|
|
585
|
+
| `reason` | Meaning |
|
|
586
|
+
| --- | --- |
|
|
587
|
+
| `frame_too_large` | frame exceeded the 64 KB wire-size cap |
|
|
588
|
+
| `too_many_events` | frame exceeded the 100-events-per-frame cap |
|
|
589
|
+
| `properties_too_large` | one event's `properties` exceeded the 4 KB cap |
|
|
590
|
+
| `no_telemetry_handler` | no `on_telemetry` callback is configured |
|
|
591
|
+
| `handler_error` | the callback raised; see your logger for the exception |
|
|
592
|
+
|
|
593
|
+
Behavior worth knowing:
|
|
594
|
+
|
|
595
|
+
- **Without `on_telemetry`, events are dropped.** The frame is still acknowledged,
|
|
596
|
+
with `accepted: 0` and `reason: "no_telemetry_handler"`, so the frontend never
|
|
597
|
+
stalls waiting on a sink that does not exist.
|
|
598
|
+
- **A callback that raises cannot break the connection.** The frame is acked as
|
|
599
|
+
dropped (`reason: "handler_error"`) and the exception goes to your logger.
|
|
600
|
+
- **A sync callback runs on the event loop.** If yours does real work — a database
|
|
601
|
+
write, an HTTP call — make it `async`, or hand off to a queue.
|
|
602
|
+
- **The producer's own caps are enforced here too** (100 events per frame, 64 KB
|
|
603
|
+
per frame, 4 KB of `properties` per event). An over-cap frame never reaches your
|
|
604
|
+
callback; it is acknowledged with `accepted: 0` and a `reason` of
|
|
605
|
+
`too_many_events`, `frame_too_large` or `properties_too_large`. A *malformed*
|
|
606
|
+
frame gets the ordinary error response instead (`INVALID_TELEMETRY_FORMAT`).
|
|
607
|
+
- **A newer `schema` is not an error.** The SDK does not gate on the version, so
|
|
608
|
+
a future FE SDK release keeps working without a backend deploy. Branch on
|
|
609
|
+
`frame["schema"]` yourself if your storage ever needs to.
|
|
610
|
+
- **`properties._dropped`** is a counter the browser SDK reports when its own buffer
|
|
611
|
+
had to evict events. It is relayed to your callback verbatim; the SDK never acts
|
|
612
|
+
on it.
|
|
613
|
+
- **`properties._properties_dropped`** replaces the entire `properties` object with
|
|
614
|
+
`{"_properties_dropped": true}` when the browser's own properties blob exceeded
|
|
615
|
+
4 KB before it was ever sent. Unlike `_dropped`, this is not a count alongside
|
|
616
|
+
intact data — it means the original properties are gone entirely, not truncated.
|
|
617
|
+
- **There is no rate limiting.** The frontend understands a
|
|
618
|
+
`reason: "rate_limited"` ack, but the SDK never sends one today.
|
|
619
|
+
|
|
483
620
|
## Advanced Usage
|
|
484
621
|
|
|
485
622
|
### Concurrent Tool Mapping (Async)
|
|
@@ -6,6 +6,8 @@ the blackbox suite exercises the published artifact rather than the source tree.
|
|
|
6
6
|
Driven over stdin so no extra web framework is needed:
|
|
7
7
|
login <user_id> -> prints "URL <websocket url>" or "URL none" (paused)
|
|
8
8
|
stats -> prints "STATS <json>"
|
|
9
|
+
telemetry_stats -> prints "STATS <json>" for the product-events queue
|
|
10
|
+
track <user> <ev> -> queues a server-side product event, prints "TRACKED true|false"
|
|
9
11
|
quit -> shuts down
|
|
10
12
|
|
|
11
13
|
Everything the SDK logs goes to LOG_FILE, which the suite scans for canaries.
|
|
@@ -45,6 +47,14 @@ def build_client() -> AsyncCentiveClient:
|
|
|
45
47
|
options["incremental_save_enabled"] = True
|
|
46
48
|
if os.environ.get("MAX_SESSIONS_PER_USER"):
|
|
47
49
|
options["max_sessions_per_user"] = int(os.environ["MAX_SESSIONS_PER_USER"])
|
|
50
|
+
if os.environ.get("TELEMETRY_FLUSH_INTERVAL_SECONDS"):
|
|
51
|
+
options["telemetry_flush_interval_seconds"] = float(
|
|
52
|
+
os.environ["TELEMETRY_FLUSH_INTERVAL_SECONDS"]
|
|
53
|
+
)
|
|
54
|
+
if os.environ.get("TELEMETRY_RATE_PER_CONNECTION_PER_MINUTE"):
|
|
55
|
+
options["telemetry_rate_per_connection_per_minute"] = int(
|
|
56
|
+
os.environ["TELEMETRY_RATE_PER_CONNECTION_PER_MINUTE"]
|
|
57
|
+
)
|
|
48
58
|
if os.environ.get("MAX_STREAM_MESSAGES_PER_SESSION"):
|
|
49
59
|
options["max_stream_messages_per_session"] = int(
|
|
50
60
|
os.environ["MAX_STREAM_MESSAGES_PER_SESSION"]
|
|
@@ -88,6 +98,17 @@ async def main() -> int:
|
|
|
88
98
|
print(f"STATS {json.dumps(stats)}", flush=True)
|
|
89
99
|
continue
|
|
90
100
|
|
|
101
|
+
if cmd == "telemetry_stats":
|
|
102
|
+
print(f"STATS {json.dumps(client.telemetry.stats())}", flush=True)
|
|
103
|
+
continue
|
|
104
|
+
|
|
105
|
+
if cmd == "track":
|
|
106
|
+
# track <user_id> <event_type>: a server-side event, as a
|
|
107
|
+
# customer backend would emit for e.g. "invoice_paid".
|
|
108
|
+
ok = client.telemetry.track(parts[1], parts[2], {"source": "customer_app"})
|
|
109
|
+
print(f"TRACKED {json.dumps(ok)}", flush=True)
|
|
110
|
+
continue
|
|
111
|
+
|
|
91
112
|
print(f"ERROR unknown command {cmd}", flush=True)
|
|
92
113
|
finally:
|
|
93
114
|
await client.aclose()
|
|
@@ -102,6 +102,22 @@ class MockApi:
|
|
|
102
102
|
if rec["path"] == "/api/anam/save-messages"
|
|
103
103
|
]
|
|
104
104
|
|
|
105
|
+
def events(self) -> list:
|
|
106
|
+
"""Recorded POST /anam/events bodies, oldest first."""
|
|
107
|
+
return [
|
|
108
|
+
rec["body"] for rec in self.recorded()
|
|
109
|
+
if rec["path"] == "/api/anam/events"
|
|
110
|
+
]
|
|
111
|
+
|
|
112
|
+
def event_batches(self) -> list:
|
|
113
|
+
"""Flattened (external_user_id, event) pairs across all recorded posts."""
|
|
114
|
+
pairs = []
|
|
115
|
+
for body in self.events():
|
|
116
|
+
for batch in body.get("batches", []):
|
|
117
|
+
for event in batch.get("events", []):
|
|
118
|
+
pairs.append((batch.get("external_user_id"), event))
|
|
119
|
+
return pairs
|
|
120
|
+
|
|
105
121
|
def set_fail_mode(self, mode: str) -> None:
|
|
106
122
|
httpx.post(f"{self.control_url}/fail", json={"mode": mode}, timeout=10).raise_for_status()
|
|
107
123
|
|
|
@@ -162,6 +178,16 @@ class CustomerApp:
|
|
|
162
178
|
assert reply.startswith("STATS "), f"unexpected reply: {reply}"
|
|
163
179
|
return json.loads(reply[6:])
|
|
164
180
|
|
|
181
|
+
def telemetry_stats(self) -> dict:
|
|
182
|
+
reply = self._command("telemetry_stats")
|
|
183
|
+
assert reply.startswith("STATS "), f"unexpected reply: {reply}"
|
|
184
|
+
return json.loads(reply[6:])
|
|
185
|
+
|
|
186
|
+
def track(self, user_id: str, event_type: str) -> bool:
|
|
187
|
+
reply = self._command(f"track {user_id} {event_type}")
|
|
188
|
+
assert reply.startswith("TRACKED "), f"unexpected reply: {reply}"
|
|
189
|
+
return json.loads(reply[8:])
|
|
190
|
+
|
|
165
191
|
def is_alive(self) -> bool:
|
|
166
192
|
return self.proc.poll() is None
|
|
167
193
|
|
|
@@ -169,6 +169,45 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
169
169
|
})
|
|
170
170
|
return
|
|
171
171
|
|
|
172
|
+
if path == "/api/anam/events":
|
|
173
|
+
if _fail_mode["mode"] == "events_503":
|
|
174
|
+
self._send(503, UPSTREAM_ERROR_BODY)
|
|
175
|
+
return
|
|
176
|
+
if _fail_mode["mode"] == "events_401":
|
|
177
|
+
self._send(401, {"detail": "Invalid API key CANARY-401-DETAIL"})
|
|
178
|
+
return
|
|
179
|
+
total = sum(len(b.get("events", [])) for b in body.get("batches", []))
|
|
180
|
+
# Users whose id starts with "unmapped" stand in for end users the
|
|
181
|
+
# real API cannot link to an account: rejected, never stored.
|
|
182
|
+
rejections = [
|
|
183
|
+
{
|
|
184
|
+
"external_user_id": b["external_user_id"],
|
|
185
|
+
"reason": "user_not_mapped_to_account",
|
|
186
|
+
"count": len(b.get("events", [])),
|
|
187
|
+
}
|
|
188
|
+
for b in body.get("batches", [])
|
|
189
|
+
if str(b.get("external_user_id", "")).startswith("unmapped")
|
|
190
|
+
]
|
|
191
|
+
rejected = sum(r["count"] for r in rejections)
|
|
192
|
+
# Users whose id starts with "live" stand in for end users with a
|
|
193
|
+
# running Aria session: Centive hands back context for the browser.
|
|
194
|
+
context = [
|
|
195
|
+
{
|
|
196
|
+
"external_user_id": b["external_user_id"],
|
|
197
|
+
"content": f"CANARY-CONTEXT-{b['external_user_id']} just opened a page.",
|
|
198
|
+
"expires_at": "2099-01-01T00:00:00Z",
|
|
199
|
+
}
|
|
200
|
+
for b in body.get("batches", [])
|
|
201
|
+
if str(b.get("external_user_id", "")).startswith("live")
|
|
202
|
+
]
|
|
203
|
+
self._send(202, {
|
|
204
|
+
"accepted": total - rejected,
|
|
205
|
+
"rejected": rejected,
|
|
206
|
+
"rejections": rejections,
|
|
207
|
+
"context": context,
|
|
208
|
+
})
|
|
209
|
+
return
|
|
210
|
+
|
|
172
211
|
if path == "/api/anam/save-messages":
|
|
173
212
|
if _fail_mode["mode"] == "save_400":
|
|
174
213
|
self._send(400, UPSTREAM_ERROR_BODY)
|