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.
Files changed (49) hide show
  1. {centive_sdk-2.1.0.dev3 → centive_sdk-2.2.0.dev4}/CHANGELOG.md +45 -0
  2. {centive_sdk-2.1.0.dev3 → centive_sdk-2.2.0.dev4}/PKG-INFO +54 -1
  3. {centive_sdk-2.1.0.dev3 → centive_sdk-2.2.0.dev4}/README.md +53 -0
  4. {centive_sdk-2.1.0.dev3 → centive_sdk-2.2.0.dev4}/blackbox/customer_app.py +21 -0
  5. {centive_sdk-2.1.0.dev3 → centive_sdk-2.2.0.dev4}/blackbox/harness.py +26 -0
  6. {centive_sdk-2.1.0.dev3 → 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.dev3 → centive_sdk-2.2.0.dev4}/pyproject.toml +1 -1
  9. {centive_sdk-2.1.0.dev3 → centive_sdk-2.2.0.dev4}/src/centive_sdk/__init__.py +12 -0
  10. {centive_sdk-2.1.0.dev3 → centive_sdk-2.2.0.dev4}/src/centive_sdk/_logging.py +5 -0
  11. {centive_sdk-2.1.0.dev3 → centive_sdk-2.2.0.dev4}/src/centive_sdk/async_client.py +36 -2
  12. {centive_sdk-2.1.0.dev3 → centive_sdk-2.2.0.dev4}/src/centive_sdk/config.py +14 -0
  13. centive_sdk-2.2.0.dev4/src/centive_sdk/models/__init__.py +27 -0
  14. {centive_sdk-2.1.0.dev3 → centive_sdk-2.2.0.dev4}/src/centive_sdk/models/requests.py +78 -2
  15. {centive_sdk-2.1.0.dev3 → centive_sdk-2.2.0.dev4}/src/centive_sdk/models/responses.py +13 -0
  16. {centive_sdk-2.1.0.dev3 → 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.dev3 → centive_sdk-2.2.0.dev4}/src/centive_sdk/resources/websocket_server.py +56 -11
  20. centive_sdk-2.2.0.dev4/tests/test_telemetry_batcher.py +267 -0
  21. {centive_sdk-2.1.0.dev3 → centive_sdk-2.2.0.dev4}/tests/test_websocket_server.py +24 -6
  22. centive_sdk-2.2.0.dev4/tests/test_websocket_telemetry.py +349 -0
  23. centive_sdk-2.1.0.dev3/src/centive_sdk/models/__init__.py +0 -10
  24. {centive_sdk-2.1.0.dev3 → centive_sdk-2.2.0.dev4}/.gitignore +0 -0
  25. {centive_sdk-2.1.0.dev3 → centive_sdk-2.2.0.dev4}/LICENSE +0 -0
  26. {centive_sdk-2.1.0.dev3 → centive_sdk-2.2.0.dev4}/blackbox/README.md +0 -0
  27. {centive_sdk-2.1.0.dev3 → centive_sdk-2.2.0.dev4}/blackbox/test_blackbox.py +0 -0
  28. {centive_sdk-2.1.0.dev3 → centive_sdk-2.2.0.dev4}/examples/async_example.py +0 -0
  29. {centive_sdk-2.1.0.dev3 → centive_sdk-2.2.0.dev4}/examples/fastapi_integration.py +0 -0
  30. {centive_sdk-2.1.0.dev3 → centive_sdk-2.2.0.dev4}/examples/sync_example.py +0 -0
  31. {centive_sdk-2.1.0.dev3 → centive_sdk-2.2.0.dev4}/examples/websocket_example.py +0 -0
  32. {centive_sdk-2.1.0.dev3 → centive_sdk-2.2.0.dev4}/src/centive_sdk/_http.py +0 -0
  33. {centive_sdk-2.1.0.dev3 → centive_sdk-2.2.0.dev4}/src/centive_sdk/_retry.py +0 -0
  34. {centive_sdk-2.1.0.dev3 → centive_sdk-2.2.0.dev4}/src/centive_sdk/client.py +0 -0
  35. {centive_sdk-2.1.0.dev3 → centive_sdk-2.2.0.dev4}/src/centive_sdk/exceptions.py +0 -0
  36. {centive_sdk-2.1.0.dev3 → centive_sdk-2.2.0.dev4}/src/centive_sdk/py.typed +0 -0
  37. {centive_sdk-2.1.0.dev3 → centive_sdk-2.2.0.dev4}/src/centive_sdk/resources/__init__.py +0 -0
  38. {centive_sdk-2.1.0.dev3 → centive_sdk-2.2.0.dev4}/src/centive_sdk/resources/message_accumulator.py +0 -0
  39. {centive_sdk-2.1.0.dev3 → centive_sdk-2.2.0.dev4}/src/centive_sdk/resources/sessions.py +0 -0
  40. {centive_sdk-2.1.0.dev3 → centive_sdk-2.2.0.dev4}/tests/__init__.py +0 -0
  41. {centive_sdk-2.1.0.dev3 → centive_sdk-2.2.0.dev4}/tests/conftest.py +0 -0
  42. {centive_sdk-2.1.0.dev3 → centive_sdk-2.2.0.dev4}/tests/test_client_async.py +0 -0
  43. {centive_sdk-2.1.0.dev3 → centive_sdk-2.2.0.dev4}/tests/test_client_sync.py +0 -0
  44. {centive_sdk-2.1.0.dev3 → centive_sdk-2.2.0.dev4}/tests/test_config.py +0 -0
  45. {centive_sdk-2.1.0.dev3 → centive_sdk-2.2.0.dev4}/tests/test_error_mapping.py +0 -0
  46. {centive_sdk-2.1.0.dev3 → centive_sdk-2.2.0.dev4}/tests/test_logging_redaction.py +0 -0
  47. {centive_sdk-2.1.0.dev3 → centive_sdk-2.2.0.dev4}/tests/test_message_accumulator.py +0 -0
  48. {centive_sdk-2.1.0.dev3 → centive_sdk-2.2.0.dev4}/tests/test_retry_logic.py +0 -0
  49. {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.1.0.dev3
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()
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "centive-sdk"
7
- version = "2.1.0.dev3"
7
+ version = "2.2.0.dev4"
8
8
  description = "Python SDK for Centive backend integration"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -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]"