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 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
- def create_connection_pool(
66
- pg_user: str, pg_host: str, pg_password: str, pg_database: str, pg_port: str | int
67
- ) -> ConnectionPool:
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=pg_database,
71
- user=pg_user,
72
- port=pg_port,
73
- password=pg_password,
74
- host=pg_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
- return ConnectionPool(
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
- if auth_response.status_code == 200:
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
- return response
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
- environment.web_ui.auth_args = {**environment.web_ui.auth_args, "error": "Invalid username or password"}
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
- return redirect(url_for("login"))
85
- except Exception:
86
- environment.web_ui.auth_args = {
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[dict] = []
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
- msg = environment._run_id.strftime("%Y-%m-%d, %H:%M:%S.%f") # type: ignore
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() as cur:
121
- cur.executemany(
122
- """
123
- INSERT INTO requests (time,run_id,greenlet_id,loadgen,name,request_type,response_time,success,response_length,exception,pid,url,context)
124
- VALUES (%(time)s, %(run_id)s, %(greenlet_id)s, %(loadgen)s, %(name)s, %(request_type)s, %(response_time)s, %(success)s, %(response_length)s, %(exception)s, %(pid)s, %(url)s, %(context)s)
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._update_end_time_task.kill()
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
- sample["response_length"] = response_length
179
+ response_length = response_length
186
180
  else:
187
- sample["response_length"] = None
181
+ response_length = None
188
182
 
189
183
  if exception:
190
184
  if isinstance(exception, CatchResponseError):
191
- sample["exception"] = str(exception)
185
+ exception = str(exception)
192
186
  else:
193
187
  try:
194
- sample["exception"] = repr(exception)
188
+ exception = repr(exception)
195
189
  except AttributeError:
196
- sample["exception"] = f"{exception.__class__} (and it has no string representation)"
190
+ exception = f"{exception.__class__} (and it has no string representation)"
197
191
  else:
198
- sample["exception"] = None
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
 
@@ -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