locust-cloud 1.6.0__py3-none-any.whl → 1.7.0__py3-none-any.whl
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.
- locust_cloud/__init__.py +26 -29
- locust_cloud/auth.py +17 -13
- locust_cloud/cloud.py +9 -0
- locust_cloud/idle_exit.py +38 -0
- locust_cloud/timescale/exporter.py +51 -41
- locust_cloud/timescale/query.py +1 -1
- locust_cloud/webui/dist/assets/{index-BrOP_HSY.js → index-zX0XW_CD.js} +89 -89
- locust_cloud/webui/dist/index.html +1 -1
- locust_cloud/webui/package.json +1 -1
- locust_cloud/webui/tsconfig.tsbuildinfo +1 -1
- locust_cloud/webui/yarn.lock +4 -4
- {locust_cloud-1.6.0.dist-info → locust_cloud-1.7.0.dist-info}/METADATA +1 -1
- locust_cloud-1.7.0.dist-info/RECORD +24 -0
- locust_cloud-1.6.0.dist-info/RECORD +0 -23
- {locust_cloud-1.6.0.dist-info → locust_cloud-1.7.0.dist-info}/WHEEL +0 -0
- {locust_cloud-1.6.0.dist-info → locust_cloud-1.7.0.dist-info}/entry_points.txt +0 -0
locust_cloud/__init__.py
CHANGED
@@ -6,13 +6,14 @@ __version__ = importlib.metadata.version("locust-cloud")
|
|
6
6
|
|
7
7
|
import argparse
|
8
8
|
import logging
|
9
|
-
import sys
|
10
9
|
|
10
|
+
import configargparse
|
11
11
|
import locust.env
|
12
12
|
import psycopg
|
13
13
|
from locust import events
|
14
14
|
from locust.argument_parser import LocustArgumentParser
|
15
15
|
from locust_cloud.auth import register_auth
|
16
|
+
from locust_cloud.idle_exit import IdleExit
|
16
17
|
from locust_cloud.timescale.exporter import Exporter
|
17
18
|
from locust_cloud.timescale.query import register_query
|
18
19
|
from psycopg.conninfo import make_conninfo
|
@@ -56,48 +57,41 @@ def add_arguments(parser: LocustArgumentParser):
|
|
56
57
|
default="",
|
57
58
|
help="Description of the test being run",
|
58
59
|
)
|
60
|
+
# do not set
|
61
|
+
# used for sending the run id from master to workers
|
62
|
+
locust_cloud.add_argument(
|
63
|
+
"--run-id",
|
64
|
+
type=str,
|
65
|
+
env_var="LOCUSTCLOUD_RUN_ID",
|
66
|
+
help=configargparse.SUPPRESS,
|
67
|
+
)
|
59
68
|
|
60
69
|
|
61
70
|
def set_autocommit(conn: psycopg.Connection):
|
62
71
|
conn.autocommit = True
|
63
72
|
|
64
73
|
|
65
|
-
|
66
|
-
|
67
|
-
|
74
|
+
@events.init.add_listener
|
75
|
+
def on_locust_init(environment: locust.env.Environment, **_args):
|
76
|
+
if not (PG_HOST and PG_USER and PG_PASSWORD and PG_DATABASE and PG_PORT):
|
77
|
+
return
|
78
|
+
|
68
79
|
try:
|
69
80
|
conninfo = make_conninfo(
|
70
|
-
dbname=
|
71
|
-
user=
|
72
|
-
port=
|
73
|
-
password=
|
74
|
-
host=
|
81
|
+
dbname=PG_DATABASE,
|
82
|
+
user=PG_USER,
|
83
|
+
port=PG_PORT,
|
84
|
+
password=PG_PASSWORD,
|
85
|
+
host=PG_HOST,
|
75
86
|
sslmode="require",
|
76
|
-
options="-c statement_timeout=55000",
|
87
|
+
# options="-c statement_timeout=55000",
|
77
88
|
)
|
78
|
-
|
89
|
+
pool = ConnectionPool(
|
79
90
|
conninfo,
|
80
91
|
min_size=1,
|
81
92
|
max_size=10,
|
82
93
|
configure=set_autocommit,
|
83
|
-
|
84
|
-
except Exception:
|
85
|
-
sys.stderr.write(f"Could not connect to postgres ({pg_user}@{pg_host}:{pg_port}).")
|
86
|
-
sys.exit(1)
|
87
|
-
|
88
|
-
|
89
|
-
@events.init.add_listener
|
90
|
-
def on_locust_init(environment: locust.env.Environment, **_args):
|
91
|
-
if not (PG_HOST and PG_USER and PG_PASSWORD and PG_DATABASE and PG_PORT):
|
92
|
-
return
|
93
|
-
|
94
|
-
try:
|
95
|
-
pool = create_connection_pool(
|
96
|
-
pg_user=PG_USER,
|
97
|
-
pg_host=PG_HOST,
|
98
|
-
pg_password=PG_PASSWORD,
|
99
|
-
pg_database=PG_DATABASE,
|
100
|
-
pg_port=PG_PORT,
|
94
|
+
check=ConnectionPool.check_connection,
|
101
95
|
)
|
102
96
|
pool.wait()
|
103
97
|
except Exception as e:
|
@@ -105,6 +99,9 @@ def on_locust_init(environment: locust.env.Environment, **_args):
|
|
105
99
|
logger.error(f"{PG_HOST=}")
|
106
100
|
raise
|
107
101
|
|
102
|
+
if not GRAPH_VIEWER:
|
103
|
+
IdleExit(environment)
|
104
|
+
|
108
105
|
if not GRAPH_VIEWER and environment.parsed_options and environment.parsed_options.exporter:
|
109
106
|
Exporter(environment, pool)
|
110
107
|
|
locust_cloud/auth.py
CHANGED
@@ -1,3 +1,4 @@
|
|
1
|
+
import logging
|
1
2
|
import os
|
2
3
|
from datetime import UTC, datetime, timedelta
|
3
4
|
from typing import TypedDict
|
@@ -12,6 +13,8 @@ from locust_cloud.constants import DEFAULT_DEPLOYER_URL
|
|
12
13
|
|
13
14
|
DEPLOYER_URL = os.environ.get("LOCUSTCLOUD_DEPLOYER_URL", DEFAULT_DEPLOYER_URL)
|
14
15
|
|
16
|
+
logger = logging.getLogger(__name__)
|
17
|
+
|
15
18
|
|
16
19
|
class Credentials(TypedDict):
|
17
20
|
user_sub_id: str
|
@@ -71,21 +74,22 @@ def register_auth(environment: locust.env.Environment):
|
|
71
74
|
headers={"X-Client-Version": __version__},
|
72
75
|
)
|
73
76
|
|
74
|
-
|
75
|
-
credentials = auth_response.json()
|
76
|
-
response = redirect(url_for("index"))
|
77
|
-
response = set_credentials(username, credentials, response)
|
78
|
-
login_user(AuthUser(credentials["user_sub_id"]))
|
77
|
+
auth_response.raise_for_status()
|
79
78
|
|
80
|
-
|
79
|
+
credentials = auth_response.json()
|
80
|
+
response = redirect(url_for("index"))
|
81
|
+
response = set_credentials(username, credentials, response)
|
82
|
+
login_user(AuthUser(credentials["user_sub_id"]))
|
81
83
|
|
82
|
-
|
84
|
+
return response
|
85
|
+
except requests.exceptions.HTTPError as e:
|
86
|
+
if e.response.status_code == 401:
|
87
|
+
environment.web_ui.auth_args["error"] = "Invalid username or password"
|
88
|
+
else:
|
89
|
+
logger.error(f"Unknown response from auth: {e.response.status_code} {e.response.text}")
|
83
90
|
|
84
|
-
|
85
|
-
|
86
|
-
|
87
|
-
**environment.web_ui.auth_args,
|
88
|
-
"error": "An unknown error occured, please try again",
|
89
|
-
}
|
91
|
+
environment.web_ui.auth_args["error"] = (
|
92
|
+
"Unknown error during authentication, check logs and/or contact support"
|
93
|
+
)
|
90
94
|
|
91
95
|
return redirect(url_for("login"))
|
locust_cloud/cloud.py
CHANGED
@@ -372,6 +372,7 @@ def main() -> None:
|
|
372
372
|
startTime=timestamp,
|
373
373
|
startFromHead=True,
|
374
374
|
)
|
375
|
+
locust_shutdown = False
|
375
376
|
for event in response.get("events", []):
|
376
377
|
message = event.get("message", "")
|
377
378
|
event_timestamp = event.get("timestamp", timestamp) + 1
|
@@ -379,9 +380,17 @@ def main() -> None:
|
|
379
380
|
message_json = json.loads(message)
|
380
381
|
if "log" in message_json:
|
381
382
|
print(message_json["log"])
|
383
|
+
|
384
|
+
if "Shutting down (exit code" in message_json["log"]:
|
385
|
+
locust_shutdown = True
|
386
|
+
|
382
387
|
except json.JSONDecodeError:
|
383
388
|
print(message)
|
384
389
|
timestamp = event_timestamp
|
390
|
+
|
391
|
+
if locust_shutdown:
|
392
|
+
break
|
393
|
+
|
385
394
|
time.sleep(5)
|
386
395
|
except ClientError as e:
|
387
396
|
error_code = e.response.get("Error", {}).get("Code", "")
|
@@ -0,0 +1,38 @@
|
|
1
|
+
import logging
|
2
|
+
import sys
|
3
|
+
|
4
|
+
import gevent
|
5
|
+
import locust.env
|
6
|
+
from locust import events
|
7
|
+
|
8
|
+
logger = logging.getLogger(__name__)
|
9
|
+
|
10
|
+
|
11
|
+
class IdleExit:
|
12
|
+
def __init__(self, environment: locust.env.Environment):
|
13
|
+
self.environment = environment
|
14
|
+
self._destroy_task: gevent.Greenlet | None = None
|
15
|
+
events.test_start.add_listener(self.on_locust_state_change)
|
16
|
+
events.test_stop.add_listener(self.on_test_stop)
|
17
|
+
events.quit.add_listener(self.on_locust_state_change)
|
18
|
+
|
19
|
+
if not self.environment.parsed_options.autostart:
|
20
|
+
self._destroy_task = gevent.spawn(self._destroy)
|
21
|
+
|
22
|
+
def _destroy(self):
|
23
|
+
gevent.sleep(1800)
|
24
|
+
logger.info("Locust was detected as idle (no test running) for more than 30 minutes")
|
25
|
+
self.environment.runner.quit()
|
26
|
+
|
27
|
+
if self.environment.web_ui:
|
28
|
+
self.environment.web_ui.greenlet.kill(timeout=5)
|
29
|
+
|
30
|
+
if self.environment.web_ui.greenlet.started:
|
31
|
+
sys.exit(1)
|
32
|
+
|
33
|
+
def on_test_stop(self, **_kwargs):
|
34
|
+
self._destroy_task = gevent.spawn(self._destroy)
|
35
|
+
|
36
|
+
def on_locust_state_change(self, **_kwargs):
|
37
|
+
if self._destroy_task:
|
38
|
+
self._destroy_task.kill()
|
@@ -22,13 +22,20 @@ def safe_serialize(obj):
|
|
22
22
|
return json.dumps(obj, default=default)
|
23
23
|
|
24
24
|
|
25
|
+
def format_datetime(d: datetime):
|
26
|
+
return d.strftime("%Y-%m-%d, %H:%M:%S.%f")
|
27
|
+
|
28
|
+
|
29
|
+
def parse_datetime(s: str):
|
30
|
+
return datetime.strptime(s, "%Y-%m-%d, %H:%M:%S.%f").replace(tzinfo=UTC)
|
31
|
+
|
32
|
+
|
25
33
|
class Exporter:
|
26
34
|
def __init__(self, environment: locust.env.Environment, pool):
|
27
35
|
self.env = environment
|
28
36
|
self._run_id = None
|
29
|
-
self._samples: list[
|
37
|
+
self._samples: list[tuple] = []
|
30
38
|
self._background = gevent.spawn(self._run)
|
31
|
-
self._update_end_time_task = gevent.spawn(self._update_end_time)
|
32
39
|
self._hostname = socket.gethostname()
|
33
40
|
self._finished = False
|
34
41
|
self._pid = os.getpid()
|
@@ -43,13 +50,6 @@ class Exporter:
|
|
43
50
|
events.spawning_complete.add_listener(self.spawning_complete)
|
44
51
|
atexit.register(self.log_stop_test_run)
|
45
52
|
|
46
|
-
if self.env.runner is not None:
|
47
|
-
self.env.runner.register_message("run_id", self.set_run_id)
|
48
|
-
|
49
|
-
def set_run_id(self, environment, msg, **kwargs):
|
50
|
-
logging.debug(f"run id from master: {msg.data}")
|
51
|
-
self._run_id = datetime.strptime(msg.data, "%Y-%m-%d, %H:%M:%S.%f").replace(tzinfo=UTC)
|
52
|
-
|
53
53
|
def on_cpu_warning(self, environment: locust.env.Environment, cpu_usage, message=None, timestamp=None, **kwargs):
|
54
54
|
# passing a custom message & timestamp to the event is a haxx to allow using this event for reporting generic events
|
55
55
|
if not timestamp:
|
@@ -64,12 +64,12 @@ class Exporter:
|
|
64
64
|
def on_test_start(self, environment: locust.env.Environment):
|
65
65
|
if not self.env.parsed_options or not self.env.parsed_options.worker:
|
66
66
|
self._run_id = environment._run_id = datetime.now(UTC) # type: ignore
|
67
|
-
|
68
|
-
if environment.runner is not None:
|
69
|
-
logging.debug(f"about to send run_id to workers: {msg}")
|
70
|
-
environment.runner.send_message("run_id", msg)
|
67
|
+
self.env.parsed_options.run_id = format_datetime(environment._run_id) # type: ignore
|
71
68
|
self.log_start_testrun()
|
72
69
|
self._user_count_logger = gevent.spawn(self._log_user_count)
|
70
|
+
self._update_end_time_task = gevent.spawn(self._update_end_time)
|
71
|
+
if self.env.parsed_options.worker:
|
72
|
+
self._run_id = parse_datetime(self.env.parsed_options.run_id)
|
73
73
|
|
74
74
|
def _log_user_count(self):
|
75
75
|
while True:
|
@@ -99,6 +99,10 @@ class Exporter:
|
|
99
99
|
gevent.sleep(0.5)
|
100
100
|
|
101
101
|
def _update_end_time(self):
|
102
|
+
# delay setting first end time
|
103
|
+
# so UI doesn't display temporary value
|
104
|
+
gevent.sleep(5)
|
105
|
+
|
102
106
|
# Regularly update endtime to prevent missing endtimes when a test crashes
|
103
107
|
while True:
|
104
108
|
current_end_time = datetime.now(UTC)
|
@@ -117,18 +121,17 @@ class Exporter:
|
|
117
121
|
try:
|
118
122
|
with self.pool.connection() as conn:
|
119
123
|
conn: psycopg.connection.Connection
|
120
|
-
with conn.cursor()
|
121
|
-
|
122
|
-
|
123
|
-
|
124
|
-
|
125
|
-
""",
|
126
|
-
samples,
|
127
|
-
)
|
124
|
+
with conn.cursor().copy(
|
125
|
+
"COPY requests (time,run_id,greenlet_id,loadgen,name,request_type,response_time,success,response_length,exception,pid,url,context) FROM STDIN"
|
126
|
+
) as copy:
|
127
|
+
for sample in samples:
|
128
|
+
copy.write_row(sample)
|
128
129
|
except psycopg.Error as error:
|
129
130
|
logging.error("Failed to write samples to Postgresql timescale database: " + repr(error))
|
130
131
|
|
131
132
|
def on_test_stop(self, environment):
|
133
|
+
if getattr(self, "_update_end_time_task", False):
|
134
|
+
self._update_end_time_task.kill()
|
132
135
|
if getattr(self, "_user_count_logger", False):
|
133
136
|
self._user_count_logger.kill()
|
134
137
|
with self.pool.connection() as conn:
|
@@ -142,7 +145,8 @@ class Exporter:
|
|
142
145
|
self._finished = True
|
143
146
|
atexit._clear() # make sure we dont capture additional ctrl-c:s
|
144
147
|
self._background.join(timeout=10)
|
145
|
-
self
|
148
|
+
if getattr(self, "_update_end_time_task", False):
|
149
|
+
self._update_end_time_task.kill()
|
146
150
|
if getattr(self, "_user_count_logger", False):
|
147
151
|
self._user_count_logger.kill()
|
148
152
|
self.log_stop_test_run(exit_code)
|
@@ -159,6 +163,9 @@ class Exporter:
|
|
159
163
|
url=None,
|
160
164
|
**kwargs,
|
161
165
|
):
|
166
|
+
# handle if a worker connects after test_start
|
167
|
+
if not self._run_id:
|
168
|
+
self._run_id = parse_datetime(self.env.parsed_options.run_id)
|
162
169
|
success = 0 if exception else 1
|
163
170
|
if start_time:
|
164
171
|
time = datetime.fromtimestamp(start_time, tz=UTC)
|
@@ -167,35 +174,38 @@ class Exporter:
|
|
167
174
|
# (which will be horribly wrong if users spend a lot of time in a with/catch_response-block)
|
168
175
|
time = datetime.now(UTC) - timedelta(milliseconds=response_time or 0)
|
169
176
|
greenlet_id = getattr(greenlet.getcurrent(), "minimal_ident", 0) # if we're debugging there is no greenlet
|
170
|
-
sample = {
|
171
|
-
"time": time,
|
172
|
-
"run_id": self._run_id,
|
173
|
-
"greenlet_id": greenlet_id,
|
174
|
-
"loadgen": self._hostname,
|
175
|
-
"name": name,
|
176
|
-
"request_type": request_type,
|
177
|
-
"response_time": response_time,
|
178
|
-
"success": success,
|
179
|
-
"url": url[0:255] if url else None,
|
180
|
-
"pid": self._pid,
|
181
|
-
"context": psycopg.types.json.Json(context, safe_serialize),
|
182
|
-
}
|
183
177
|
|
184
178
|
if response_length >= 0:
|
185
|
-
|
179
|
+
response_length = response_length
|
186
180
|
else:
|
187
|
-
|
181
|
+
response_length = None
|
188
182
|
|
189
183
|
if exception:
|
190
184
|
if isinstance(exception, CatchResponseError):
|
191
|
-
|
185
|
+
exception = str(exception)
|
192
186
|
else:
|
193
187
|
try:
|
194
|
-
|
188
|
+
exception = repr(exception)
|
195
189
|
except AttributeError:
|
196
|
-
|
190
|
+
exception = f"{exception.__class__} (and it has no string representation)"
|
197
191
|
else:
|
198
|
-
|
192
|
+
exception = None
|
193
|
+
|
194
|
+
sample = (
|
195
|
+
time,
|
196
|
+
self._run_id,
|
197
|
+
greenlet_id,
|
198
|
+
self._hostname,
|
199
|
+
name,
|
200
|
+
request_type,
|
201
|
+
response_time,
|
202
|
+
success,
|
203
|
+
response_length,
|
204
|
+
exception,
|
205
|
+
self._pid,
|
206
|
+
url[0:255] if url else None,
|
207
|
+
psycopg.types.json.Json(context, safe_serialize),
|
208
|
+
)
|
199
209
|
|
200
210
|
self._samples.append(sample)
|
201
211
|
|
locust_cloud/timescale/query.py
CHANGED
@@ -21,7 +21,7 @@ def register_query(environment, pool):
|
|
21
21
|
# start_time = time.perf_counter()
|
22
22
|
with pool.connection() as conn:
|
23
23
|
# get_conn_time = (time.perf_counter() - start_time) * 1000
|
24
|
-
sql_params = request.get_json()
|
24
|
+
sql_params = request.get_json() if request.content_type == "application/json" else {}
|
25
25
|
# start_time = time.perf_counter()
|
26
26
|
from datetime import datetime, timedelta
|
27
27
|
|