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.
- featrixevents-0.1.0/PKG-INFO +26 -0
- featrixevents-0.1.0/__init__.py +29 -0
- featrixevents-0.1.0/_post_event.py +87 -0
- featrixevents-0.1.0/featrixevents.egg-info/PKG-INFO +26 -0
- featrixevents-0.1.0/featrixevents.egg-info/SOURCES.txt +12 -0
- featrixevents-0.1.0/featrixevents.egg-info/dependency_links.txt +1 -0
- featrixevents-0.1.0/featrixevents.egg-info/not-zip-safe +1 -0
- featrixevents-0.1.0/featrixevents.egg-info/requires.txt +1 -0
- featrixevents-0.1.0/featrixevents.egg-info/top_level.txt +1 -0
- featrixevents-0.1.0/setup.cfg +4 -0
- featrixevents-0.1.0/setup.py +50 -0
- featrixevents-0.1.0/tests/test_e2e_collect_and_build.py +313 -0
|
@@ -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
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
requests>=2.20.0
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
featrixevents
|
|
@@ -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
|
+
)
|