featrixevents 0.1.0__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.
@@ -0,0 +1,26 @@
1
+ Metadata-Version: 2.4
2
+ Name: featrixevents
3
+ Version: 0.1.0
4
+ Summary: Post events to the Featrix platform.
5
+ Home-page: https://github.com/Featrix/sphere
6
+ Author: Featrix
7
+ Author-email: support@featrix.com
8
+ Classifier: Development Status :: 4 - Beta
9
+ Classifier: Intended Audience :: Developers
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Operating System :: OS Independent
12
+ Classifier: Programming Language :: Python :: 3
13
+ Requires-Python: >=3.8
14
+ Description-Content-Type: text/plain
15
+ Requires-Dist: requests>=2.20.0
16
+ Dynamic: author
17
+ Dynamic: author-email
18
+ Dynamic: classifier
19
+ Dynamic: description
20
+ Dynamic: description-content-type
21
+ Dynamic: home-page
22
+ Dynamic: requires-dist
23
+ Dynamic: requires-python
24
+ Dynamic: summary
25
+
26
+ Post events to the Featrix platform. See https://docs.featrix.com
@@ -0,0 +1,29 @@
1
+ # -*- coding: utf-8 -*-
2
+ #
3
+ # Copyright (c) 2023-2026 Featrix, Inc, All Rights Reserved
4
+ #
5
+ # Proprietary and Confidential. Unauthorized use, copying or dissemination
6
+ # of these materials is strictly prohibited.
7
+ #
8
+
9
+ """
10
+ Featrix Events - Post events to the Featrix platform.
11
+
12
+ Usage:
13
+ from featrixevents import featrix_post_event
14
+
15
+ featrix_post_event(
16
+ auth_key_id="fx_...",
17
+ event_group_id="<uuid>",
18
+ event_payload={"action": "button_click", "page": "/dashboard"}
19
+ )
20
+ """
21
+
22
+ __version__ = "0.1.0"
23
+ __author__ = "Featrix"
24
+ __email__ = "support@featrix.com"
25
+ __license__ = "MIT"
26
+
27
+ from ._post_event import featrix_post_event, FeatrixEventError
28
+
29
+ __all__ = ["featrix_post_event", "FeatrixEventError"]
@@ -0,0 +1,87 @@
1
+ # -*- coding: utf-8 -*-
2
+ #
3
+ # Copyright (c) 2023-2026 Featrix, Inc, All Rights Reserved
4
+ #
5
+ # Proprietary and Confidential. Unauthorized use, copying or dissemination
6
+ # of these materials is strictly prohibited.
7
+ #
8
+
9
+ """Core implementation of featrix_post_event."""
10
+
11
+ import os
12
+
13
+ import requests
14
+
15
+ DEFAULT_BASE_URL = "https://sphere-api.featrix.com"
16
+
17
+
18
+ class FeatrixEventError(Exception):
19
+ """Raised when posting an event fails."""
20
+
21
+ def __init__(self, message, status_code=None, response_body=None):
22
+ super().__init__(message)
23
+ self.status_code = status_code
24
+ self.response_body = response_body
25
+
26
+
27
+ def featrix_post_event(
28
+ auth_key_id: str,
29
+ event_group_id: str,
30
+ event_payload: dict,
31
+ base_url: str = None,
32
+ timeout: float = 30.0,
33
+ ) -> dict:
34
+ """
35
+ Post an event to the Featrix platform.
36
+
37
+ Args:
38
+ auth_key_id: API key (e.g. "fx_..."). NOT a JWT.
39
+ event_group_id: UUID string grouping related events.
40
+ event_payload: Arbitrary dict to store as the event payload.
41
+ base_url: Server URL. Defaults to FEATRIX_BASE_URL env var
42
+ or "https://sphere-api.featrix.com".
43
+ timeout: Request timeout in seconds.
44
+
45
+ Returns:
46
+ Dict with "success" (bool) and "event_id" (str UUID).
47
+
48
+ Raises:
49
+ FeatrixEventError: On auth failure, server error, or connection error.
50
+ """
51
+ if base_url is None:
52
+ base_url = os.getenv("FEATRIX_BASE_URL", DEFAULT_BASE_URL)
53
+
54
+ url = f"{base_url.rstrip('/')}/events/ingest"
55
+
56
+ headers = {
57
+ "X-Api-Key": auth_key_id,
58
+ "Content-Type": "application/json",
59
+ }
60
+
61
+ body = {
62
+ "event_group_id": event_group_id,
63
+ "event_payload": event_payload,
64
+ }
65
+
66
+ try:
67
+ resp = requests.post(url, json=body, headers=headers, timeout=timeout)
68
+ except requests.ConnectionError as e:
69
+ raise FeatrixEventError(f"Connection error: {e}") from e
70
+ except requests.Timeout as e:
71
+ raise FeatrixEventError(f"Request timed out after {timeout}s") from e
72
+
73
+ if resp.status_code in (200, 201):
74
+ return resp.json()
75
+
76
+ # Error path
77
+ try:
78
+ error_body = resp.json()
79
+ error_msg = error_body.get("error", resp.text)
80
+ except Exception:
81
+ error_msg = resp.text
82
+
83
+ raise FeatrixEventError(
84
+ f"Event post failed (HTTP {resp.status_code}): {error_msg}",
85
+ status_code=resp.status_code,
86
+ response_body=resp.text,
87
+ )
@@ -0,0 +1,26 @@
1
+ Metadata-Version: 2.4
2
+ Name: featrixevents
3
+ Version: 0.1.0
4
+ Summary: Post events to the Featrix platform.
5
+ Home-page: https://github.com/Featrix/sphere
6
+ Author: Featrix
7
+ Author-email: support@featrix.com
8
+ Classifier: Development Status :: 4 - Beta
9
+ Classifier: Intended Audience :: Developers
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Operating System :: OS Independent
12
+ Classifier: Programming Language :: Python :: 3
13
+ Requires-Python: >=3.8
14
+ Description-Content-Type: text/plain
15
+ Requires-Dist: requests>=2.20.0
16
+ Dynamic: author
17
+ Dynamic: author-email
18
+ Dynamic: classifier
19
+ Dynamic: description
20
+ Dynamic: description-content-type
21
+ Dynamic: home-page
22
+ Dynamic: requires-dist
23
+ Dynamic: requires-python
24
+ Dynamic: summary
25
+
26
+ Post events to the Featrix platform. See https://docs.featrix.com
@@ -0,0 +1,12 @@
1
+ __init__.py
2
+ _post_event.py
3
+ setup.py
4
+ ./__init__.py
5
+ ./_post_event.py
6
+ featrixevents.egg-info/PKG-INFO
7
+ featrixevents.egg-info/SOURCES.txt
8
+ featrixevents.egg-info/dependency_links.txt
9
+ featrixevents.egg-info/not-zip-safe
10
+ featrixevents.egg-info/requires.txt
11
+ featrixevents.egg-info/top_level.txt
12
+ tests/test_e2e_collect_and_build.py
@@ -0,0 +1 @@
1
+ requests>=2.20.0
@@ -0,0 +1 @@
1
+ featrixevents
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,50 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ #
4
+ # Copyright (c) 2023-2026 Featrix, Inc, All Rights Reserved
5
+ #
6
+ # Proprietary and Confidential. Unauthorized use, copying or dissemination
7
+ # of these materials is strictly prohibited.
8
+ #
9
+
10
+ """Setup script for featrixevents package."""
11
+
12
+ from setuptools import setup
13
+ from pathlib import Path
14
+
15
+ this_directory = Path(__file__).parent
16
+
17
+
18
+ def get_version():
19
+ init_file = this_directory / "__init__.py"
20
+ if init_file.exists():
21
+ for line in init_file.read_text().splitlines():
22
+ if line.startswith("__version__"):
23
+ return line.split('"')[1]
24
+ return "0.1.0"
25
+
26
+
27
+ setup(
28
+ name="featrixevents",
29
+ version=get_version(),
30
+ author="Featrix",
31
+ author_email="support@featrix.com",
32
+ description="Post events to the Featrix platform.",
33
+ long_description="Post events to the Featrix platform. See https://docs.featrix.com",
34
+ long_description_content_type="text/plain",
35
+ url="https://github.com/Featrix/sphere",
36
+ packages=["featrixevents"],
37
+ package_dir={"featrixevents": "."},
38
+ classifiers=[
39
+ "Development Status :: 4 - Beta",
40
+ "Intended Audience :: Developers",
41
+ "License :: OSI Approved :: MIT License",
42
+ "Operating System :: OS Independent",
43
+ "Programming Language :: Python :: 3",
44
+ ],
45
+ python_requires=">=3.8",
46
+ install_requires=[
47
+ "requests>=2.20.0",
48
+ ],
49
+ zip_safe=False,
50
+ )
@@ -0,0 +1,313 @@
1
+ # -*- coding: utf-8 -*-
2
+ #
3
+ # Copyright (c) 2023-2026 Featrix, Inc, All Rights Reserved
4
+ #
5
+ # Proprietary and Confidential. Unauthorized use, copying or dissemination
6
+ # of these materials is strictly prohibited.
7
+ #
8
+
9
+ """
10
+ End-to-end test for the featrixevents library.
11
+
12
+ Posts ~500 synthetic events with featrix_post_event(), then calls the
13
+ new /events/build-model endpoint to confirm Featrix can:
14
+ 1. Receive and store events via the public ingest API.
15
+ 2. Materialize those events back into a training dataset.
16
+ 3. Spin up a real foundational-model training session.
17
+
18
+ This is a *real* network test — no mocks. It writes rows to the
19
+ production user_events table under a namespaced event_group_id so the
20
+ data is identifiable and harmless.
21
+
22
+ Run with:
23
+ FEATRIX_API_KEY=fx_... pytest featrixevents/tests/test_e2e_collect_and_build.py -s
24
+
25
+ Skips automatically if FEATRIX_API_KEY is not set.
26
+ """
27
+
28
+ import os
29
+ import random
30
+ import time
31
+ import uuid
32
+ from concurrent.futures import ThreadPoolExecutor, as_completed
33
+
34
+ import pytest
35
+ import requests
36
+
37
+ from featrixevents import featrix_post_event, FeatrixEventError
38
+
39
+
40
+ DEFAULT_BASE_URL = os.getenv("FEATRIX_BASE_URL", "https://sphere-api.featrix.com")
41
+ API_KEY = os.getenv("FEATRIX_API_KEY")
42
+
43
+ NUM_EVENTS = 500
44
+ INGEST_WORKERS = 16 # well under the 100 events/sec/org rate limit at ~200ms latency
45
+ INGEST_MAX_SECONDS = 120
46
+ BUILD_POLL_INTERVAL = 15
47
+ # Stall-detector: if no status/progress change for this many seconds, fail.
48
+ TRAIN_STALL_TIMEOUT = 900
49
+ # Hard wall-clock cap so a test run can't wedge a CI box forever.
50
+ TRAIN_WALL_TIMEOUT = 3600
51
+
52
+ # Columns we synthesize. `outcome` is the learnable target.
53
+ EXPECTED_COLUMNS = {
54
+ "user_tier",
55
+ "pages_viewed",
56
+ "time_on_site",
57
+ "prior_purchases",
58
+ "source",
59
+ "outcome",
60
+ }
61
+
62
+
63
+ pytestmark = pytest.mark.skipif(
64
+ not API_KEY,
65
+ reason="FEATRIX_API_KEY not set — e2e test needs a real ingest key",
66
+ )
67
+
68
+
69
+ def _make_synthetic_event(rng: random.Random) -> dict:
70
+ """One synthetic page-session event with a learnable outcome.
71
+
72
+ `outcome` is biased by user_tier + prior_purchases + time_on_site so a
73
+ foundational model + SP should beat the class prior. We're not asserting
74
+ on model quality here, just on round-trip + training completion — but
75
+ we want the data to be non-degenerate so training has something to do.
76
+ """
77
+ tier = rng.choices(
78
+ ["free", "pro", "enterprise"],
79
+ weights=[0.6, 0.3, 0.1],
80
+ )[0]
81
+ source = rng.choice(["organic", "ads", "referral", "email", "social"])
82
+ pages = max(1, int(rng.gauss(4, 2)))
83
+ time_on_site = round(max(2.0, rng.gauss(45.0, 25.0)), 1)
84
+ prior_purchases = rng.choices(
85
+ [0, 1, 2, 3, 5, 10],
86
+ weights=[0.45, 0.25, 0.15, 0.08, 0.05, 0.02],
87
+ )[0]
88
+
89
+ # Bias the target so it's learnable.
90
+ score = 0.0
91
+ score += {"free": 0.0, "pro": 0.4, "enterprise": 0.7}[tier]
92
+ score += min(prior_purchases / 5.0, 1.0) * 0.5
93
+ score += (time_on_site / 120.0) * 0.3
94
+ score += rng.gauss(0, 0.2)
95
+ outcome = "converted" if score > 0.55 else "not_converted"
96
+
97
+ return {
98
+ "user_tier": tier,
99
+ "pages_viewed": pages,
100
+ "time_on_site": time_on_site,
101
+ "prior_purchases": prior_purchases,
102
+ "source": source,
103
+ "outcome": outcome,
104
+ }
105
+
106
+
107
+ def _ingest_one(event_group_id: str, payload: dict) -> str:
108
+ """Post one event. Returns event_id. Re-raises on failure."""
109
+ result = featrix_post_event(
110
+ auth_key_id=API_KEY,
111
+ event_group_id=event_group_id,
112
+ event_payload=payload,
113
+ base_url=DEFAULT_BASE_URL,
114
+ timeout=30.0,
115
+ )
116
+ assert result.get("success") is True, f"ingest result missing success: {result}"
117
+ event_id = result.get("event_id")
118
+ assert event_id, f"ingest result missing event_id: {result}"
119
+ return event_id
120
+
121
+
122
+ def _post_build_model(event_group_id: str, name: str) -> dict:
123
+ """Call /events/build-model and return the JSON response."""
124
+ url = f"{DEFAULT_BASE_URL.rstrip('/')}/events/build-model"
125
+ body = {
126
+ "event_group_id": event_group_id,
127
+ "name": name,
128
+ "max_events": NUM_EVENTS * 2,
129
+ }
130
+ resp = requests.post(
131
+ url,
132
+ headers={"X-Api-Key": API_KEY, "Content-Type": "application/json"},
133
+ json=body,
134
+ timeout=1800,
135
+ )
136
+ if resp.status_code not in (200, 201, 202):
137
+ raise AssertionError(
138
+ f"build-model failed: HTTP {resp.status_code}: {resp.text[:1000]}"
139
+ )
140
+ return resp.json()
141
+
142
+
143
+ def _get_session(session_id: str) -> dict:
144
+ url = f"{DEFAULT_BASE_URL.rstrip('/')}/compute/session/{session_id}"
145
+ resp = requests.get(
146
+ url,
147
+ headers={"X-Api-Key": API_KEY},
148
+ timeout=60,
149
+ )
150
+ if resp.status_code != 200:
151
+ raise AssertionError(
152
+ f"session lookup failed: HTTP {resp.status_code}: {resp.text[:500]}"
153
+ )
154
+ return resp.json()
155
+
156
+
157
+ def _wait_for_training(session_id: str) -> dict:
158
+ """Poll /compute/session/{id} until status leaves the in-flight set.
159
+
160
+ Stall-detector: if neither status nor any job's status changes for
161
+ TRAIN_STALL_TIMEOUT seconds, fail. Also enforces TRAIN_WALL_TIMEOUT
162
+ as a hard cap.
163
+ """
164
+ in_flight = {"queued", "starting", "running", "training", "in_progress", None, ""}
165
+ success_states = {"done", "completed", "finished", "ready"}
166
+ failure_states = {"failed", "error", "aborted", "cancelled"}
167
+
168
+ started = time.time()
169
+ last_change = time.time()
170
+ last_fingerprint = None
171
+ last_seen = None
172
+
173
+ while True:
174
+ now = time.time()
175
+ if now - started > TRAIN_WALL_TIMEOUT:
176
+ pytest.fail(
177
+ f"Wall-clock timeout ({TRAIN_WALL_TIMEOUT}s) waiting on session "
178
+ f"{session_id}. Last seen: {last_seen}"
179
+ )
180
+ if now - last_change > TRAIN_STALL_TIMEOUT:
181
+ pytest.fail(
182
+ f"Stall timeout: no status change for {TRAIN_STALL_TIMEOUT}s on "
183
+ f"session {session_id}. Last seen: {last_seen}"
184
+ )
185
+
186
+ try:
187
+ payload = _get_session(session_id)
188
+ except AssertionError as e:
189
+ # 404 right after dispatch can happen if the session is still
190
+ # being registered. Tolerate a few before failing.
191
+ if now - started < 60:
192
+ time.sleep(BUILD_POLL_INTERVAL)
193
+ continue
194
+ raise
195
+
196
+ session = payload.get("session", payload)
197
+ status = (session.get("status") or "").lower()
198
+ jobs = payload.get("jobs") or {}
199
+ job_summary = tuple(
200
+ sorted(
201
+ (jid, (j.get("status") or "").lower())
202
+ for jid, j in jobs.items()
203
+ )
204
+ )
205
+ fingerprint = (status, job_summary)
206
+ last_seen = {"status": status, "jobs": dict(jobs)}
207
+
208
+ if fingerprint != last_fingerprint:
209
+ last_change = now
210
+ last_fingerprint = fingerprint
211
+ print(
212
+ f"[{int(now - started)}s] session={session_id[:12]}... "
213
+ f"status={status!r} jobs={len(jobs)}"
214
+ )
215
+
216
+ if status in failure_states:
217
+ pytest.fail(
218
+ f"Training failed: session={session_id}, status={status}, "
219
+ f"jobs={jobs}"
220
+ )
221
+
222
+ if status in success_states:
223
+ return payload
224
+
225
+ if status not in in_flight:
226
+ # Unknown status — surface it so we can learn what's happening.
227
+ print(f"[warn] unknown session status {status!r}; continuing to poll")
228
+
229
+ time.sleep(BUILD_POLL_INTERVAL)
230
+
231
+
232
+ def test_e2e_collect_events_and_build_model():
233
+ """Post 500 events, fetch them back via /events/build-model, train an ES.
234
+
235
+ Asserts:
236
+ - All 500 events are accepted by the ingest endpoint.
237
+ - /events/build-model reports the right row count and column set.
238
+ - Training reaches a terminal success state without manual help.
239
+ """
240
+ assert API_KEY, "FEATRIX_API_KEY is required for this test"
241
+
242
+ event_group_id = str(uuid.uuid4())
243
+ run_label = f"featrixevents-e2e-{event_group_id[:8]}"
244
+ rng = random.Random(0xFEA72) # deterministic synthetic data
245
+
246
+ print(f"\n==> e2e run: event_group_id={event_group_id} label={run_label}")
247
+ print(f"==> base url: {DEFAULT_BASE_URL}")
248
+ print(f"==> posting {NUM_EVENTS} events with {INGEST_WORKERS} workers")
249
+
250
+ # --- Phase 1: ingest events in parallel -------------------------------
251
+ payloads = [_make_synthetic_event(rng) for _ in range(NUM_EVENTS)]
252
+
253
+ ingest_start = time.time()
254
+ event_ids: list = []
255
+ errors: list = []
256
+ with ThreadPoolExecutor(max_workers=INGEST_WORKERS) as pool:
257
+ futures = [
258
+ pool.submit(_ingest_one, event_group_id, p) for p in payloads
259
+ ]
260
+ for fut in as_completed(futures, timeout=INGEST_MAX_SECONDS):
261
+ try:
262
+ event_ids.append(fut.result())
263
+ except (FeatrixEventError, AssertionError, Exception) as e:
264
+ errors.append(repr(e))
265
+
266
+ ingest_elapsed = time.time() - ingest_start
267
+ print(f"==> ingest done in {ingest_elapsed:.1f}s: "
268
+ f"{len(event_ids)} ok, {len(errors)} failed")
269
+ if errors:
270
+ # Show the first few errors so the failure mode is obvious.
271
+ for e in errors[:5]:
272
+ print(f" err: {e}")
273
+ assert not errors, f"{len(errors)} ingest calls failed (first: {errors[0] if errors else None})"
274
+ assert len(event_ids) == NUM_EVENTS, (
275
+ f"Expected {NUM_EVENTS} event_ids, got {len(event_ids)}"
276
+ )
277
+ # All event_ids should be unique UUIDs.
278
+ assert len(set(event_ids)) == NUM_EVENTS, "Server returned duplicate event_ids"
279
+
280
+ # --- Phase 2: build a model from the collected events -----------------
281
+ print(f"==> calling /events/build-model")
282
+ build_result = _post_build_model(event_group_id, run_label)
283
+ print(f"==> build-model returned: "
284
+ f"session_id={build_result.get('session_id')} "
285
+ f"num_events={build_result.get('num_events')} "
286
+ f"queued={build_result.get('queued')} "
287
+ f"cols={build_result.get('columns')}")
288
+
289
+ assert build_result.get("success") is True, build_result
290
+ assert build_result.get("num_events") == NUM_EVENTS, (
291
+ f"Expected {NUM_EVENTS} events from build-model, "
292
+ f"got {build_result.get('num_events')}"
293
+ )
294
+ columns = set(build_result.get("columns") or [])
295
+ missing = EXPECTED_COLUMNS - columns
296
+ assert not missing, (
297
+ f"build-model flattened columns are missing {missing}; got {columns}"
298
+ )
299
+ session_id = build_result.get("session_id")
300
+ assert session_id, "build-model returned no session_id"
301
+
302
+ # --- Phase 3: wait for the foundational-model training to finish -----
303
+ print(f"==> waiting for training on session {session_id}")
304
+ final_payload = _wait_for_training(session_id)
305
+
306
+ session = final_payload.get("session", final_payload)
307
+ final_status = (session.get("status") or "").lower()
308
+ print(f"==> final session status: {final_status}")
309
+ print(f"==> session dump: {session}")
310
+ # _wait_for_training only returns on terminal success.
311
+ assert final_status in {"done", "completed", "finished", "ready"}, (
312
+ f"Unexpected terminal status: {final_status}"
313
+ )