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.
Files changed (50) hide show
  1. {centive_sdk-2.1.0.dev2 → centive_sdk-2.2.0.dev4}/CHANGELOG.md +86 -0
  2. {centive_sdk-2.1.0.dev2 → centive_sdk-2.2.0.dev4}/PKG-INFO +138 -1
  3. {centive_sdk-2.1.0.dev2 → centive_sdk-2.2.0.dev4}/README.md +137 -0
  4. {centive_sdk-2.1.0.dev2 → centive_sdk-2.2.0.dev4}/blackbox/customer_app.py +21 -0
  5. {centive_sdk-2.1.0.dev2 → centive_sdk-2.2.0.dev4}/blackbox/harness.py +26 -0
  6. {centive_sdk-2.1.0.dev2 → centive_sdk-2.2.0.dev4}/blackbox/mock_centive_api.py +39 -0
  7. centive_sdk-2.2.0.dev4/blackbox/test_blackbox_telemetry.py +250 -0
  8. {centive_sdk-2.1.0.dev2 → centive_sdk-2.2.0.dev4}/pyproject.toml +1 -1
  9. {centive_sdk-2.1.0.dev2 → centive_sdk-2.2.0.dev4}/src/centive_sdk/__init__.py +12 -0
  10. {centive_sdk-2.1.0.dev2 → centive_sdk-2.2.0.dev4}/src/centive_sdk/_logging.py +5 -0
  11. {centive_sdk-2.1.0.dev2 → centive_sdk-2.2.0.dev4}/src/centive_sdk/async_client.py +43 -0
  12. {centive_sdk-2.1.0.dev2 → centive_sdk-2.2.0.dev4}/src/centive_sdk/config.py +24 -0
  13. centive_sdk-2.2.0.dev4/src/centive_sdk/models/__init__.py +27 -0
  14. {centive_sdk-2.1.0.dev2 → centive_sdk-2.2.0.dev4}/src/centive_sdk/models/requests.py +78 -2
  15. {centive_sdk-2.1.0.dev2 → centive_sdk-2.2.0.dev4}/src/centive_sdk/models/responses.py +13 -0
  16. {centive_sdk-2.1.0.dev2 → centive_sdk-2.2.0.dev4}/src/centive_sdk/resources/async_sessions.py +39 -2
  17. centive_sdk-2.2.0.dev4/src/centive_sdk/resources/async_telemetry.py +65 -0
  18. centive_sdk-2.2.0.dev4/src/centive_sdk/resources/telemetry_batcher.py +341 -0
  19. {centive_sdk-2.1.0.dev2 → centive_sdk-2.2.0.dev4}/src/centive_sdk/resources/websocket_server.py +270 -1
  20. {centive_sdk-2.1.0.dev2 → centive_sdk-2.2.0.dev4}/tests/conftest.py +36 -0
  21. centive_sdk-2.2.0.dev4/tests/test_telemetry_batcher.py +267 -0
  22. {centive_sdk-2.1.0.dev2 → centive_sdk-2.2.0.dev4}/tests/test_websocket_security.py +233 -0
  23. centive_sdk-2.2.0.dev4/tests/test_websocket_server.py +987 -0
  24. centive_sdk-2.2.0.dev4/tests/test_websocket_telemetry.py +349 -0
  25. centive_sdk-2.1.0.dev2/src/centive_sdk/models/__init__.py +0 -10
  26. centive_sdk-2.1.0.dev2/tests/test_websocket_server.py +0 -419
  27. {centive_sdk-2.1.0.dev2 → centive_sdk-2.2.0.dev4}/.gitignore +0 -0
  28. {centive_sdk-2.1.0.dev2 → centive_sdk-2.2.0.dev4}/LICENSE +0 -0
  29. {centive_sdk-2.1.0.dev2 → centive_sdk-2.2.0.dev4}/blackbox/README.md +0 -0
  30. {centive_sdk-2.1.0.dev2 → centive_sdk-2.2.0.dev4}/blackbox/test_blackbox.py +0 -0
  31. {centive_sdk-2.1.0.dev2 → centive_sdk-2.2.0.dev4}/examples/async_example.py +0 -0
  32. {centive_sdk-2.1.0.dev2 → centive_sdk-2.2.0.dev4}/examples/fastapi_integration.py +0 -0
  33. {centive_sdk-2.1.0.dev2 → centive_sdk-2.2.0.dev4}/examples/sync_example.py +0 -0
  34. {centive_sdk-2.1.0.dev2 → centive_sdk-2.2.0.dev4}/examples/websocket_example.py +0 -0
  35. {centive_sdk-2.1.0.dev2 → centive_sdk-2.2.0.dev4}/src/centive_sdk/_http.py +0 -0
  36. {centive_sdk-2.1.0.dev2 → centive_sdk-2.2.0.dev4}/src/centive_sdk/_retry.py +0 -0
  37. {centive_sdk-2.1.0.dev2 → centive_sdk-2.2.0.dev4}/src/centive_sdk/client.py +0 -0
  38. {centive_sdk-2.1.0.dev2 → centive_sdk-2.2.0.dev4}/src/centive_sdk/exceptions.py +0 -0
  39. {centive_sdk-2.1.0.dev2 → centive_sdk-2.2.0.dev4}/src/centive_sdk/py.typed +0 -0
  40. {centive_sdk-2.1.0.dev2 → centive_sdk-2.2.0.dev4}/src/centive_sdk/resources/__init__.py +0 -0
  41. {centive_sdk-2.1.0.dev2 → centive_sdk-2.2.0.dev4}/src/centive_sdk/resources/message_accumulator.py +0 -0
  42. {centive_sdk-2.1.0.dev2 → centive_sdk-2.2.0.dev4}/src/centive_sdk/resources/sessions.py +0 -0
  43. {centive_sdk-2.1.0.dev2 → centive_sdk-2.2.0.dev4}/tests/__init__.py +0 -0
  44. {centive_sdk-2.1.0.dev2 → centive_sdk-2.2.0.dev4}/tests/test_client_async.py +0 -0
  45. {centive_sdk-2.1.0.dev2 → centive_sdk-2.2.0.dev4}/tests/test_client_sync.py +0 -0
  46. {centive_sdk-2.1.0.dev2 → centive_sdk-2.2.0.dev4}/tests/test_config.py +0 -0
  47. {centive_sdk-2.1.0.dev2 → centive_sdk-2.2.0.dev4}/tests/test_error_mapping.py +0 -0
  48. {centive_sdk-2.1.0.dev2 → centive_sdk-2.2.0.dev4}/tests/test_logging_redaction.py +0 -0
  49. {centive_sdk-2.1.0.dev2 → centive_sdk-2.2.0.dev4}/tests/test_message_accumulator.py +0 -0
  50. {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.1.0.dev2
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)