centive-sdk 2.1.0.dev3__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.dev3 → centive_sdk-2.2.0.dev4}/CHANGELOG.md +45 -0
- {centive_sdk-2.1.0.dev3 → centive_sdk-2.2.0.dev4}/PKG-INFO +54 -1
- {centive_sdk-2.1.0.dev3 → centive_sdk-2.2.0.dev4}/README.md +53 -0
- {centive_sdk-2.1.0.dev3 → centive_sdk-2.2.0.dev4}/blackbox/customer_app.py +21 -0
- {centive_sdk-2.1.0.dev3 → centive_sdk-2.2.0.dev4}/blackbox/harness.py +26 -0
- {centive_sdk-2.1.0.dev3 → 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.dev3 → centive_sdk-2.2.0.dev4}/pyproject.toml +1 -1
- {centive_sdk-2.1.0.dev3 → centive_sdk-2.2.0.dev4}/src/centive_sdk/__init__.py +12 -0
- {centive_sdk-2.1.0.dev3 → centive_sdk-2.2.0.dev4}/src/centive_sdk/_logging.py +5 -0
- {centive_sdk-2.1.0.dev3 → centive_sdk-2.2.0.dev4}/src/centive_sdk/async_client.py +36 -2
- {centive_sdk-2.1.0.dev3 → centive_sdk-2.2.0.dev4}/src/centive_sdk/config.py +14 -0
- centive_sdk-2.2.0.dev4/src/centive_sdk/models/__init__.py +27 -0
- {centive_sdk-2.1.0.dev3 → centive_sdk-2.2.0.dev4}/src/centive_sdk/models/requests.py +78 -2
- {centive_sdk-2.1.0.dev3 → centive_sdk-2.2.0.dev4}/src/centive_sdk/models/responses.py +13 -0
- {centive_sdk-2.1.0.dev3 → 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.dev3 → centive_sdk-2.2.0.dev4}/src/centive_sdk/resources/websocket_server.py +56 -11
- centive_sdk-2.2.0.dev4/tests/test_telemetry_batcher.py +267 -0
- {centive_sdk-2.1.0.dev3 → centive_sdk-2.2.0.dev4}/tests/test_websocket_server.py +24 -6
- centive_sdk-2.2.0.dev4/tests/test_websocket_telemetry.py +349 -0
- centive_sdk-2.1.0.dev3/src/centive_sdk/models/__init__.py +0 -10
- {centive_sdk-2.1.0.dev3 → centive_sdk-2.2.0.dev4}/.gitignore +0 -0
- {centive_sdk-2.1.0.dev3 → centive_sdk-2.2.0.dev4}/LICENSE +0 -0
- {centive_sdk-2.1.0.dev3 → centive_sdk-2.2.0.dev4}/blackbox/README.md +0 -0
- {centive_sdk-2.1.0.dev3 → centive_sdk-2.2.0.dev4}/blackbox/test_blackbox.py +0 -0
- {centive_sdk-2.1.0.dev3 → centive_sdk-2.2.0.dev4}/examples/async_example.py +0 -0
- {centive_sdk-2.1.0.dev3 → centive_sdk-2.2.0.dev4}/examples/fastapi_integration.py +0 -0
- {centive_sdk-2.1.0.dev3 → centive_sdk-2.2.0.dev4}/examples/sync_example.py +0 -0
- {centive_sdk-2.1.0.dev3 → centive_sdk-2.2.0.dev4}/examples/websocket_example.py +0 -0
- {centive_sdk-2.1.0.dev3 → centive_sdk-2.2.0.dev4}/src/centive_sdk/_http.py +0 -0
- {centive_sdk-2.1.0.dev3 → centive_sdk-2.2.0.dev4}/src/centive_sdk/_retry.py +0 -0
- {centive_sdk-2.1.0.dev3 → centive_sdk-2.2.0.dev4}/src/centive_sdk/client.py +0 -0
- {centive_sdk-2.1.0.dev3 → centive_sdk-2.2.0.dev4}/src/centive_sdk/exceptions.py +0 -0
- {centive_sdk-2.1.0.dev3 → centive_sdk-2.2.0.dev4}/src/centive_sdk/py.typed +0 -0
- {centive_sdk-2.1.0.dev3 → centive_sdk-2.2.0.dev4}/src/centive_sdk/resources/__init__.py +0 -0
- {centive_sdk-2.1.0.dev3 → centive_sdk-2.2.0.dev4}/src/centive_sdk/resources/message_accumulator.py +0 -0
- {centive_sdk-2.1.0.dev3 → centive_sdk-2.2.0.dev4}/src/centive_sdk/resources/sessions.py +0 -0
- {centive_sdk-2.1.0.dev3 → centive_sdk-2.2.0.dev4}/tests/__init__.py +0 -0
- {centive_sdk-2.1.0.dev3 → centive_sdk-2.2.0.dev4}/tests/conftest.py +0 -0
- {centive_sdk-2.1.0.dev3 → centive_sdk-2.2.0.dev4}/tests/test_client_async.py +0 -0
- {centive_sdk-2.1.0.dev3 → centive_sdk-2.2.0.dev4}/tests/test_client_sync.py +0 -0
- {centive_sdk-2.1.0.dev3 → centive_sdk-2.2.0.dev4}/tests/test_config.py +0 -0
- {centive_sdk-2.1.0.dev3 → centive_sdk-2.2.0.dev4}/tests/test_error_mapping.py +0 -0
- {centive_sdk-2.1.0.dev3 → centive_sdk-2.2.0.dev4}/tests/test_logging_redaction.py +0 -0
- {centive_sdk-2.1.0.dev3 → centive_sdk-2.2.0.dev4}/tests/test_message_accumulator.py +0 -0
- {centive_sdk-2.1.0.dev3 → centive_sdk-2.2.0.dev4}/tests/test_retry_logic.py +0 -0
- {centive_sdk-2.1.0.dev3 → centive_sdk-2.2.0.dev4}/tests/test_websocket_security.py +0 -0
|
@@ -2,6 +2,51 @@
|
|
|
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
|
+
|
|
5
50
|
## v2.1.0
|
|
6
51
|
|
|
7
52
|
Unreleased
|
|
@@ -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
|
|
@@ -195,6 +195,11 @@ an expired connection token.
|
|
|
195
195
|
| `ws_host` | `str` | `0.0.0.0` | websocket bind address (`127.0.0.1` behind a proxy) |
|
|
196
196
|
| `ws_port` | `int` | `8765` | websocket server port |
|
|
197
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 |
|
|
198
203
|
| `ws_token_ttl_seconds`| `float` | `3600.0` | how long a connection token stays valid |
|
|
199
204
|
| `ws_allowed_origins` | `list[str]` | `None` | allow-list of Origin headers for WS handshakes |
|
|
200
205
|
| `ws_handshake_secret` | `str` | `None` | static shared secret (legacy `"open"` mode only) |
|
|
@@ -518,6 +523,54 @@ client = AsyncCentiveClient(
|
|
|
518
523
|
|
|
519
524
|
For details on WebSocket event formats, see `websocket_messages.md`.
|
|
520
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
|
+
|
|
521
574
|
## Page Telemetry
|
|
522
575
|
|
|
523
576
|
The FE SDK (`@centive/aria-sdk` 1.1.0+) sends page-view and page-leave events over
|
|
@@ -162,6 +162,11 @@ an expired connection token.
|
|
|
162
162
|
| `ws_host` | `str` | `0.0.0.0` | websocket bind address (`127.0.0.1` behind a proxy) |
|
|
163
163
|
| `ws_port` | `int` | `8765` | websocket server port |
|
|
164
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 |
|
|
165
170
|
| `ws_token_ttl_seconds`| `float` | `3600.0` | how long a connection token stays valid |
|
|
166
171
|
| `ws_allowed_origins` | `list[str]` | `None` | allow-list of Origin headers for WS handshakes |
|
|
167
172
|
| `ws_handshake_secret` | `str` | `None` | static shared secret (legacy `"open"` mode only) |
|
|
@@ -485,6 +490,54 @@ client = AsyncCentiveClient(
|
|
|
485
490
|
|
|
486
491
|
For details on WebSocket event formats, see `websocket_messages.md`.
|
|
487
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
|
+
|
|
488
541
|
## Page Telemetry
|
|
489
542
|
|
|
490
543
|
The FE SDK (`@centive/aria-sdk` 1.1.0+) sends page-view and page-leave events over
|
|
@@ -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)
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
"""Blackbox coverage for the ``telemetry`` frame (product events / page tracking).
|
|
2
|
+
|
|
3
|
+
Same stance as test_blackbox.py: the SDK is an opaque process, mallory holds a
|
|
4
|
+
legitimate token, and every upstream value is a canary. What must hold:
|
|
5
|
+
|
|
6
|
+
- Events mallory sends are attributed to mallory, never to the user_id in the
|
|
7
|
+
frame body.
|
|
8
|
+
- Events reach the Centive API in the C2 shape with the org API key.
|
|
9
|
+
- Upstream failures (503, 401) never surface to the browser and never leak
|
|
10
|
+
their bodies into the customer's logs.
|
|
11
|
+
- A telemetry violation never closes the socket.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import asyncio
|
|
15
|
+
import json
|
|
16
|
+
import sys
|
|
17
|
+
import uuid
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
import pytest
|
|
21
|
+
import websockets
|
|
22
|
+
|
|
23
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
24
|
+
|
|
25
|
+
from harness import CustomerApp, MockApi # noqa: E402
|
|
26
|
+
from mock_centive_api import API_KEY # noqa: E402
|
|
27
|
+
|
|
28
|
+
pytestmark = pytest.mark.asyncio
|
|
29
|
+
|
|
30
|
+
RECV_TIMEOUT = 10.0
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@pytest.fixture(scope="module")
|
|
34
|
+
def mock_api():
|
|
35
|
+
api = MockApi()
|
|
36
|
+
yield api
|
|
37
|
+
api.stop()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@pytest.fixture(scope="module")
|
|
41
|
+
def app(mock_api, tmp_path_factory):
|
|
42
|
+
log = tmp_path_factory.mktemp("logs") / "customer.log"
|
|
43
|
+
application = CustomerApp(mock_api, log, TELEMETRY_FLUSH_INTERVAL_SECONDS="0.2")
|
|
44
|
+
yield application
|
|
45
|
+
application.stop()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@pytest.fixture(autouse=True)
|
|
49
|
+
def _reset(mock_api):
|
|
50
|
+
mock_api.reset()
|
|
51
|
+
yield
|
|
52
|
+
mock_api.set_fail_mode("none")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
async def recv_json(ws):
|
|
56
|
+
return json.loads(await asyncio.wait_for(ws.recv(), timeout=RECV_TIMEOUT))
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
async def connect(url):
|
|
60
|
+
ws = await websockets.connect(url, open_timeout=RECV_TIMEOUT)
|
|
61
|
+
await recv_json(ws) # session payload on connect
|
|
62
|
+
return ws
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
async def wait_for(predicate, timeout=15.0, interval=0.1):
|
|
66
|
+
loop = asyncio.get_running_loop()
|
|
67
|
+
deadline = loop.time() + timeout
|
|
68
|
+
while loop.time() < deadline:
|
|
69
|
+
value = predicate()
|
|
70
|
+
if value:
|
|
71
|
+
return value
|
|
72
|
+
await asyncio.sleep(interval)
|
|
73
|
+
return None
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def page_view(path="/CANARY-PATH/42", **over):
|
|
77
|
+
base = {
|
|
78
|
+
"client_event_id": str(uuid.uuid4()),
|
|
79
|
+
"event_type": "page_view",
|
|
80
|
+
"occurred_at": "2026-08-27T10:00:00Z",
|
|
81
|
+
"browser_session_id": "bs_1",
|
|
82
|
+
"page": {"path": path, "pattern": "/CANARY-PATH/:id", "title": "CANARY-TITLE"},
|
|
83
|
+
"detection": "history",
|
|
84
|
+
"properties": {},
|
|
85
|
+
}
|
|
86
|
+
base.update(over)
|
|
87
|
+
return base
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def frame(user_id, events):
|
|
91
|
+
return json.dumps({
|
|
92
|
+
"type": "telemetry", "schema": 1, "user_id": user_id,
|
|
93
|
+
"sdk": "aria-sdk/1.0.0", "events": events,
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
async def test_events_are_forwarded_in_c2_shape_with_api_key(app, mock_api):
|
|
98
|
+
ws = await connect(app.login("alice"))
|
|
99
|
+
try:
|
|
100
|
+
marker = f"/pages/{uuid.uuid4()}"
|
|
101
|
+
await ws.send(frame("alice", [page_view(path=marker)]))
|
|
102
|
+
ack = await recv_json(ws)
|
|
103
|
+
assert ack["type"] == "telemetry_ack" and ack["accepted"] == 1
|
|
104
|
+
|
|
105
|
+
posted = await wait_for(lambda: [
|
|
106
|
+
b for b in mock_api.events()
|
|
107
|
+
if any(ev["page"]["path"] == marker for bt in b["batches"] for ev in bt["events"])
|
|
108
|
+
])
|
|
109
|
+
assert posted, "event never reached the API"
|
|
110
|
+
body = posted[0]
|
|
111
|
+
assert body["schema"] == 1
|
|
112
|
+
assert body["sdk_version"].startswith("python/")
|
|
113
|
+
batch = next(b for b in body["batches"] if b["external_user_id"] == "alice")
|
|
114
|
+
assert batch["client_sdk"] == "aria-sdk/1.0.0"
|
|
115
|
+
|
|
116
|
+
keys = [rec["api_key_header"] for rec in mock_api.recorded() if rec["path"] == "/api/anam/events"]
|
|
117
|
+
assert keys and all(k == API_KEY for k in keys)
|
|
118
|
+
finally:
|
|
119
|
+
await ws.close()
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
async def test_forged_user_id_never_reaches_api_and_socket_stays_open(app, mock_api):
|
|
123
|
+
"""Mallory claims to be alice inside the frame body."""
|
|
124
|
+
ws = await connect(app.login("mallory"))
|
|
125
|
+
try:
|
|
126
|
+
marker = f"/forged/{uuid.uuid4()}"
|
|
127
|
+
await ws.send(frame("alice", [page_view(path=marker)]))
|
|
128
|
+
first = await recv_json(ws)
|
|
129
|
+
assert first.get("error") == "IDENTITY_MISMATCH"
|
|
130
|
+
|
|
131
|
+
# Still connected: an honest frame goes through.
|
|
132
|
+
honest = f"/honest/{uuid.uuid4()}"
|
|
133
|
+
await ws.send(frame("mallory", [page_view(path=honest)]))
|
|
134
|
+
assert (await recv_json(ws))["accepted"] == 1
|
|
135
|
+
|
|
136
|
+
await wait_for(lambda: any(e["page"]["path"] == honest for _, e in mock_api.event_batches()))
|
|
137
|
+
attributed = [(uid, e["page"]["path"]) for uid, e in mock_api.event_batches()]
|
|
138
|
+
assert (None, marker) not in attributed
|
|
139
|
+
assert all(path != marker for _, path in attributed), "forged event was forwarded"
|
|
140
|
+
assert all(uid != "alice" for uid, _ in attributed), "event attributed to the victim"
|
|
141
|
+
finally:
|
|
142
|
+
await ws.close()
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
async def test_frame_without_user_id_is_attributed_to_bound_user(app, mock_api):
|
|
146
|
+
ws = await connect(app.login("bob"))
|
|
147
|
+
try:
|
|
148
|
+
marker = f"/nouser/{uuid.uuid4()}"
|
|
149
|
+
payload = json.loads(frame("x", [page_view(path=marker)]))
|
|
150
|
+
del payload["user_id"]
|
|
151
|
+
await ws.send(json.dumps(payload))
|
|
152
|
+
assert (await recv_json(ws))["accepted"] == 1
|
|
153
|
+
pairs = await wait_for(lambda: [p for p in mock_api.event_batches() if p[1]["page"]["path"] == marker])
|
|
154
|
+
assert pairs and pairs[0][0] == "bob"
|
|
155
|
+
finally:
|
|
156
|
+
await ws.close()
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
async def test_upstream_503_body_never_reaches_browser_or_logs(app, mock_api):
|
|
160
|
+
mock_api.set_fail_mode("events_503")
|
|
161
|
+
ws = await connect(app.login("alice"))
|
|
162
|
+
try:
|
|
163
|
+
await ws.send(frame("alice", [page_view()]))
|
|
164
|
+
assert (await recv_json(ws))["accepted"] == 1
|
|
165
|
+
# The SDK retries 5xx with backoff before recording a failure, so
|
|
166
|
+
# wait for the retries to run out rather than for the first POST.
|
|
167
|
+
stats = await wait_for(
|
|
168
|
+
lambda: (lambda s: s if s["flush_failures"] >= 1 else None)(app.telemetry_stats()),
|
|
169
|
+
timeout=30,
|
|
170
|
+
)
|
|
171
|
+
assert stats is not None, "flush never recorded the upstream failure"
|
|
172
|
+
assert stats["queue_depth"] >= 1 # kept for retry, not lost
|
|
173
|
+
with pytest.raises(asyncio.TimeoutError):
|
|
174
|
+
await asyncio.wait_for(ws.recv(), timeout=1.0)
|
|
175
|
+
finally:
|
|
176
|
+
await ws.close()
|
|
177
|
+
logs = app.logs()
|
|
178
|
+
assert "CANARY-UPSTREAM-ERROR-BODY" not in logs
|
|
179
|
+
assert "CANARY-UPSTREAM-DSN" not in logs
|
|
180
|
+
assert API_KEY not in logs
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
async def test_upstream_401_is_dropped_quietly_without_leaking(app, mock_api):
|
|
184
|
+
mock_api.set_fail_mode("events_401")
|
|
185
|
+
ws = await connect(app.login("alice"))
|
|
186
|
+
try:
|
|
187
|
+
await ws.send(frame("alice", [page_view()]))
|
|
188
|
+
assert (await recv_json(ws))["accepted"] == 1
|
|
189
|
+
await wait_for(lambda: mock_api.events(), timeout=10)
|
|
190
|
+
await wait_for(lambda: app.telemetry_stats()["dropped_non_retryable"] >= 1, timeout=10)
|
|
191
|
+
with pytest.raises(asyncio.TimeoutError):
|
|
192
|
+
await asyncio.wait_for(ws.recv(), timeout=1.0)
|
|
193
|
+
finally:
|
|
194
|
+
await ws.close()
|
|
195
|
+
assert API_KEY not in app.logs()
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
async def test_page_paths_do_not_appear_in_info_logs(app, mock_api):
|
|
199
|
+
ws = await connect(app.login("alice"))
|
|
200
|
+
try:
|
|
201
|
+
await ws.send(frame("alice", [page_view(path="/CANARY-SECRET-PATH/99")]))
|
|
202
|
+
assert (await recv_json(ws))["accepted"] == 1
|
|
203
|
+
await wait_for(lambda: mock_api.events())
|
|
204
|
+
finally:
|
|
205
|
+
await ws.close()
|
|
206
|
+
info_lines = [line for line in app.logs().splitlines() if not line.startswith("DEBUG")]
|
|
207
|
+
assert not any("CANARY-SECRET-PATH" in line for line in info_lines)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
async def test_rejections_from_api_are_final_and_counted(app, mock_api):
|
|
211
|
+
ws = await connect(app.login("unmapped_carol"))
|
|
212
|
+
try:
|
|
213
|
+
await ws.send(frame("unmapped_carol", [page_view(), page_view()]))
|
|
214
|
+
assert (await recv_json(ws))["accepted"] == 2
|
|
215
|
+
await wait_for(lambda: mock_api.events())
|
|
216
|
+
stats = await wait_for(lambda: (lambda s: s if s["rejected"] >= 2 else None)(app.telemetry_stats()))
|
|
217
|
+
assert stats["rejections_by_reason"].get("user_not_mapped_to_account", 0) >= 2
|
|
218
|
+
posts_before = len(mock_api.events())
|
|
219
|
+
await asyncio.sleep(0.8)
|
|
220
|
+
assert len(mock_api.events()) == posts_before, "rejected events were retried"
|
|
221
|
+
finally:
|
|
222
|
+
await ws.close()
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
async def test_server_side_track_is_forwarded(app, mock_api):
|
|
226
|
+
assert app.track("alice", "invoice_paid") is True
|
|
227
|
+
pairs = await wait_for(lambda: [p for p in mock_api.event_batches() if p[1]["event_type"] == "invoice_paid"])
|
|
228
|
+
assert pairs and pairs[0][0] == "alice"
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
async def test_live_context_reaches_only_its_own_user(app, mock_api):
|
|
232
|
+
"""Centive's context for live_alice must arrive on alice's socket and never
|
|
233
|
+
on another connected user's socket."""
|
|
234
|
+
alice = await connect(app.login("live_alice"))
|
|
235
|
+
mallory = await connect(app.login("mallory"))
|
|
236
|
+
try:
|
|
237
|
+
await alice.send(frame("live_alice", [page_view()]))
|
|
238
|
+
assert (await recv_json(alice))["type"] == "telemetry_ack"
|
|
239
|
+
ctx = await recv_json(alice)
|
|
240
|
+
assert ctx["type"] == "context"
|
|
241
|
+
assert ctx["schema"] == 1
|
|
242
|
+
assert ctx["content"] == "CANARY-CONTEXT-live_alice just opened a page."
|
|
243
|
+
assert ctx["source"] == "centive"
|
|
244
|
+
with pytest.raises(asyncio.TimeoutError):
|
|
245
|
+
await asyncio.wait_for(mallory.recv(), timeout=1.0)
|
|
246
|
+
stats = await wait_for(lambda: (lambda s: s if s["context_delivered"] >= 1 else None)(app.telemetry_stats()))
|
|
247
|
+
assert stats is not None
|
|
248
|
+
finally:
|
|
249
|
+
await alice.close()
|
|
250
|
+
await mallory.close()
|
|
@@ -14,21 +14,27 @@ from .exceptions import (
|
|
|
14
14
|
from .models.requests import (
|
|
15
15
|
MessageHistoryEvent,
|
|
16
16
|
MessageStreamEvent,
|
|
17
|
+
ProductEventBatchRequest,
|
|
17
18
|
SaveMessagesRequest,
|
|
18
19
|
SessionEndEvent,
|
|
19
20
|
SessionMessage,
|
|
20
21
|
SessionMetadata,
|
|
21
22
|
StreamMessage,
|
|
23
|
+
TelemetryEvent,
|
|
24
|
+
TelemetryFrame,
|
|
25
|
+
TelemetryUserBatch,
|
|
22
26
|
ToolMappingRequest,
|
|
23
27
|
TriggerSessionRequest,
|
|
24
28
|
)
|
|
25
29
|
from .models.responses import (
|
|
26
30
|
PauseStatusResponse,
|
|
31
|
+
ProductEventIngestResponse,
|
|
27
32
|
SaveMessagesResponse,
|
|
28
33
|
ToolMappingResponse,
|
|
29
34
|
TriggerSessionResponse,
|
|
30
35
|
)
|
|
31
36
|
from .resources.message_accumulator import MessageAccumulator
|
|
37
|
+
from .resources.telemetry_batcher import TelemetryBatcher
|
|
32
38
|
from .resources.websocket_server import WebSocketServer
|
|
33
39
|
|
|
34
40
|
try:
|
|
@@ -52,14 +58,20 @@ __all__ = [
|
|
|
52
58
|
"MessageHistoryEvent",
|
|
53
59
|
"MessageStreamEvent",
|
|
54
60
|
"SessionEndEvent",
|
|
61
|
+
"TelemetryEvent",
|
|
62
|
+
"TelemetryFrame",
|
|
63
|
+
"TelemetryUserBatch",
|
|
64
|
+
"ProductEventBatchRequest",
|
|
55
65
|
# Response Models
|
|
56
66
|
"ToolMappingResponse",
|
|
57
67
|
"TriggerSessionResponse",
|
|
58
68
|
"SaveMessagesResponse",
|
|
59
69
|
"PauseStatusResponse",
|
|
70
|
+
"ProductEventIngestResponse",
|
|
60
71
|
# Resources
|
|
61
72
|
"WebSocketServer",
|
|
62
73
|
"MessageAccumulator",
|
|
74
|
+
"TelemetryBatcher",
|
|
63
75
|
# Exceptions
|
|
64
76
|
"CentiveError",
|
|
65
77
|
"AuthError",
|
|
@@ -72,6 +72,11 @@ def redact_request_data(data: dict) -> dict:
|
|
|
72
72
|
if "company_name" in redacted:
|
|
73
73
|
redacted["company_name"] = mask_string(str(redacted["company_name"]))
|
|
74
74
|
|
|
75
|
+
# Page paths and titles can carry identifiers from the customer's app.
|
|
76
|
+
for path_field in ("page_path", "page_title", "path", "title", "referrer_path"):
|
|
77
|
+
if path_field in redacted:
|
|
78
|
+
redacted[path_field] = mask_string(str(redacted[path_field]))
|
|
79
|
+
|
|
75
80
|
for secret_field in ("api_key", "token", "connection_token", "ws_handshake_secret"):
|
|
76
81
|
if secret_field in redacted:
|
|
77
82
|
redacted[secret_field] = "[REDACTED]"
|