locust-cloud 1.5.3__py3-none-any.whl → 1.5.5__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
@@ -3,8 +3,10 @@ import os
3
3
  os.environ["LOCUST_SKIP_MONKEY_PATCH"] = "1"
4
4
 
5
5
  import argparse
6
+ import logging
6
7
  import sys
7
8
 
9
+ import locust.env
8
10
  import psycopg
9
11
  from locust import events
10
12
  from locust.argument_parser import LocustArgumentParser
@@ -19,6 +21,7 @@ PG_PASSWORD = os.environ.get("PG_PASSWORD")
19
21
  PG_DATABASE = os.environ.get("PG_DATABASE")
20
22
  PG_PORT = os.environ.get("PG_PORT", 5432)
21
23
  GRAPH_VIEWER = os.environ.get("GRAPH_VIEWER")
24
+ logger = logging.getLogger(__name__)
22
25
 
23
26
 
24
27
  @events.init_command_line_parser.add_listener
@@ -55,7 +58,9 @@ def set_autocommit(conn: psycopg.Connection):
55
58
  conn.autocommit = True
56
59
 
57
60
 
58
- def create_connection_pool(pg_user, pg_host, pg_password, pg_database, pg_port):
61
+ def create_connection_pool(
62
+ pg_user: str, pg_host: str, pg_password: str, pg_database: str, pg_port: str | int
63
+ ) -> ConnectionPool:
59
64
  try:
60
65
  return ConnectionPool(
61
66
  conninfo=f"postgres://{pg_user}:{pg_password}@{pg_host}:{pg_port}/{pg_database}?sslmode=require",
@@ -69,19 +74,28 @@ def create_connection_pool(pg_user, pg_host, pg_password, pg_database, pg_port):
69
74
 
70
75
 
71
76
  @events.init.add_listener
72
- def on_locust_init(environment, **_args):
73
- if not (PG_HOST or GRAPH_VIEWER):
77
+ def on_locust_init(environment: locust.env.Environment, **_args):
78
+ if not (PG_HOST and PG_USER and PG_PASSWORD and PG_DATABASE and PG_PORT):
74
79
  return
75
80
 
76
- pool = create_connection_pool(
77
- pg_user=PG_USER,
78
- pg_host=PG_HOST,
79
- pg_password=PG_PASSWORD,
80
- pg_database=PG_DATABASE,
81
- pg_port=PG_PORT,
82
- )
81
+ if GRAPH_VIEWER:
82
+ environment.web_ui.template_args["isGraphViewer"] = True
83
+
84
+ try:
85
+ pool = create_connection_pool(
86
+ pg_user=PG_USER,
87
+ pg_host=PG_HOST,
88
+ pg_password=PG_PASSWORD,
89
+ pg_database=PG_DATABASE,
90
+ pg_port=PG_PORT,
91
+ )
92
+ pool.wait()
93
+ except Exception as e:
94
+ logger.exception(e)
95
+ logger.error(f"{PG_HOST=}")
96
+ raise
83
97
 
84
- if not GRAPH_VIEWER and environment.parsed_options.exporter:
98
+ if not GRAPH_VIEWER and environment.parsed_options and environment.parsed_options.exporter:
85
99
  Exporter(environment, pool)
86
100
 
87
101
  if environment.web_ui:
locust_cloud/auth.py CHANGED
@@ -1,80 +1,75 @@
1
1
  import os
2
2
  from datetime import UTC, datetime, timedelta
3
+ from typing import TypedDict
3
4
 
5
+ import locust.env
4
6
  import requests
7
+ import werkzeug
5
8
  from flask import redirect, request, url_for
6
- from flask_login import UserMixin, current_user, login_user
7
- from locust_cloud.constants import DEFAULT_LAMBDA_URL
9
+ from flask_login import UserMixin, login_user
8
10
 
9
- LAMBDA = os.environ.get("LOCUST_API_BASE_URL", DEFAULT_LAMBDA_URL)
11
+
12
+ class Credentials(TypedDict):
13
+ user_sub_id: str
14
+ refresh_token: str
10
15
 
11
16
 
12
17
  class AuthUser(UserMixin):
13
- def __init__(self, user_sub_id):
18
+ def __init__(self, user_sub_id: str):
14
19
  self.user_sub_id = user_sub_id
15
20
 
16
21
  def get_id(self):
17
22
  return self.user_sub_id
18
23
 
19
24
 
20
- def set_credentials(credentials, response):
21
- if not credentials.get("cognito_client_id_token"):
25
+ def set_credentials(username: str, credentials: Credentials, response: werkzeug.wrappers.response.Response):
26
+ if not credentials.get("user_sub_id"):
22
27
  return response
23
28
 
24
- id_token = credentials["cognito_client_id_token"]
25
29
  user_sub_id = credentials["user_sub_id"]
26
30
  refresh_token = credentials["refresh_token"]
27
31
 
28
- response.set_cookie("cognito_token", id_token, expires=datetime.now(tz=UTC) + timedelta(days=1))
32
+ response.set_cookie("username", username, expires=datetime.now(tz=UTC) + timedelta(days=365))
29
33
  response.set_cookie("user_token", refresh_token, expires=datetime.now(tz=UTC) + timedelta(days=365))
30
34
  response.set_cookie("user_sub_id", user_sub_id, expires=datetime.now(tz=UTC) + timedelta(days=365))
31
35
 
32
36
  return response
33
37
 
34
38
 
35
- def load_user(user_sub_id):
36
- refresh_token = request.cookies.get("user_token")
39
+ def register_auth(environment: locust.env.Environment):
40
+ environment.web_ui.app.config["SECRET_KEY"] = os.getenv("SECRET_KEY")
41
+ environment.web_ui.app.debug = False
37
42
 
38
- if refresh_token:
39
- return AuthUser(user_sub_id)
43
+ def load_user(user_sub_id: str):
44
+ username = request.cookies.get("username")
45
+ refresh_token = request.cookies.get("user_token")
40
46
 
41
- return None
47
+ if refresh_token:
48
+ environment.web_ui.template_args["username"] = username
49
+ return AuthUser(user_sub_id)
42
50
 
51
+ return None
43
52
 
44
- def register_auth(environment):
45
- environment.web_ui.app.config["SECRET_KEY"] = os.getenv("SECRET_KEY")
46
- environment.web_ui.app.debug = False
47
53
  environment.web_ui.login_manager.user_loader(load_user)
48
54
  environment.web_ui.auth_args = {
49
55
  "username_password_callback": "/authenticate",
50
56
  }
51
57
 
52
- @environment.web_ui.app.after_request
53
- def refresh_handler(response):
54
- if request.path == "/" and current_user:
55
- refresh_token = request.cookies.get("user_token")
56
- user_sub_id = request.cookies.get("user_sub_id")
57
- if user_sub_id and refresh_token:
58
- auth_response = requests.post(
59
- f"{LAMBDA}/auth/login", json={"user_sub_id": user_sub_id, "refresh_token": refresh_token}
60
- )
61
- credentials = auth_response.json()
62
- response = set_credentials(credentials, response)
63
-
64
- return response
65
-
66
58
  @environment.web_ui.app.route("/authenticate", methods=["POST"])
67
59
  def login_submit():
68
- username = request.form.get("username")
60
+ username = request.form.get("username", "")
69
61
  password = request.form.get("password")
70
62
 
71
63
  try:
72
- auth_response = requests.post(f"{LAMBDA}/auth/login", json={"username": username, "password": password})
64
+ auth_response = requests.post(
65
+ f"{environment.parsed_options.deployer_url}/auth/login",
66
+ json={"username": username, "password": password},
67
+ )
73
68
 
74
69
  if auth_response.status_code == 200:
75
70
  credentials = auth_response.json()
76
71
  response = redirect(url_for("index"))
77
- response = set_credentials(credentials, response)
72
+ response = set_credentials(username, credentials, response)
78
73
  login_user(AuthUser(credentials["user_sub_id"]))
79
74
 
80
75
  return response
locust_cloud/cloud.py CHANGED
@@ -16,14 +16,13 @@ import requests
16
16
  from botocore.exceptions import ClientError
17
17
  from locust_cloud.constants import (
18
18
  DEFAULT_CLUSTER_NAME,
19
- DEFAULT_LAMBDA_URL,
19
+ DEFAULT_DEPLOYER_URL,
20
20
  DEFAULT_NAMESPACE,
21
21
  DEFAULT_REGION_NAME,
22
22
  USERS_PER_WORKER,
23
23
  )
24
24
  from locust_cloud.credential_manager import CredentialError, CredentialManager
25
25
 
26
- LOCUST_ENV_VARIABLE_IGNORE_LIST = ["LOCUST_BUILD_PATH", "LOCUST_SKIP_MONKEY_PATCH"]
27
26
  __version__ = importlib.metadata.version("locust-cloud")
28
27
 
29
28
 
@@ -57,7 +56,7 @@ parser = configargparse.ArgumentParser(
57
56
  "~/.cloud.conf",
58
57
  "cloud.conf",
59
58
  ],
60
- auto_env_var_prefix="LOCUST_",
59
+ auto_env_var_prefix="LOCUSTCLOUD_",
61
60
  formatter_class=configargparse.RawDescriptionHelpFormatter,
62
61
  config_file_parser_class=configargparse.CompositeConfigParser(
63
62
  [
@@ -67,7 +66,7 @@ parser = configargparse.ArgumentParser(
67
66
  ),
68
67
  description="""Launches distributed Locust runs on locust.cloud infrastructure.
69
68
 
70
- Example: locust-cloud -f my_locustfile.py --aws-region-name us-east-1 --users 1000""",
69
+ Example: locust-cloud -f my_locustfile.py --region us-east-1 --users 2000""",
71
70
  epilog="""Any parameters not listed here are forwarded to locust master unmodified, so go ahead and use things like --users, --host, --run-time, ...
72
71
  Locust config can also be set using config file (~/.locust.conf, locust.conf, pyproject.toml, ~/.cloud.conf or cloud.conf).
73
72
  Parameters specified on command line override env vars, which in turn override config files.""",
@@ -88,21 +87,19 @@ parser.add_argument(
88
87
  "--users",
89
88
  type=int,
90
89
  default=1,
91
- help="Number of users to launch. This is the same as the regular Locust argument, but also decides how many workers to launch by default.",
90
+ help="Number of users to launch. This is the same as the regular Locust argument, but also affects how many workers to launch.",
92
91
  env_var="LOCUST_USERS",
93
92
  )
94
93
  parser.add_argument(
95
94
  "--requirements",
96
95
  type=str,
97
96
  help="Optional requirements.txt file that contains your external libraries.",
98
- env_var="LOCUST_REQUIREMENTS",
99
97
  )
100
98
  parser.add_argument(
101
- "--aws-region-name",
99
+ "--region",
102
100
  type=str,
103
- default=DEFAULT_REGION_NAME,
104
- help="Sets the region to use for the deployed cluster",
105
- env_var="AWS_REGION_NAME",
101
+ default=os.environ.get("AWS_DEFAULT_REGION", DEFAULT_REGION_NAME),
102
+ help="Sets the AWS region to use for the deployed cluster, e.g. us-east-1. It defaults to use AWS_DEFAULT_REGION env var, like AWS tools.",
106
103
  )
107
104
  parser.add_argument(
108
105
  "--kube-cluster-name",
@@ -119,11 +116,10 @@ parser.add_argument(
119
116
  env_var="KUBE_NAMESPACE",
120
117
  )
121
118
  parser.add_argument(
122
- "--lambda-url",
119
+ "--deployer-url",
123
120
  type=str,
124
- default=DEFAULT_LAMBDA_URL,
125
- help="Sets the namespace for scoping the deployed cluster",
126
- env_var="LOCUST_API_BASE_URL",
121
+ default=DEFAULT_DEPLOYER_URL,
122
+ help=configargparse.SUPPRESS,
127
123
  )
128
124
  parser.add_argument(
129
125
  "--aws-access-key-id",
@@ -143,29 +139,25 @@ parser.add_argument(
143
139
  "--username",
144
140
  type=str,
145
141
  help="Authentication for deploying with Locust Cloud",
146
- env_var="LOCUST_CLOUD_USERNAME",
147
- default=None,
142
+ default=os.getenv("LOCUST_CLOUD_USERNAME", None), # backwards compatitibility for dmdb
148
143
  )
149
144
  parser.add_argument(
150
145
  "--password",
151
146
  type=str,
152
147
  help="Authentication for deploying with Locust Cloud",
153
- env_var="LOCUST_CLOUD_PASSWORD",
154
- default=None,
148
+ default=os.getenv("LOCUST_CLOUD_PASSWORD", None), # backwards compatitibility for dmdb
155
149
  )
156
150
  parser.add_argument(
157
151
  "--loglevel",
158
152
  "-L",
159
153
  type=str,
160
154
  help="Log level",
161
- env_var="LOCUST_CLOUD_LOGLEVEL",
162
155
  default="INFO",
163
156
  )
164
157
  parser.add_argument(
165
158
  "--workers",
166
159
  type=int,
167
- help="Number of workers to use for the deployment",
168
- env_var="LOCUST_CLOUD_WORKERS",
160
+ help=f"Number of workers to use for the deployment. Defaults to number of users divided by {USERS_PER_WORKER}",
169
161
  default=None,
170
162
  )
171
163
  parser.add_argument(
@@ -176,7 +168,6 @@ parser.add_argument(
176
168
  parser.add_argument(
177
169
  "--image-tag",
178
170
  type=str,
179
- env_var="LOCUST_CLOUD_IMAGE_TAG",
180
171
  default="latest",
181
172
  help=configargparse.SUPPRESS, # overrides the locust-cloud docker image tag. for internal use
182
173
  )
@@ -202,6 +193,7 @@ def main() -> None:
202
193
  s3_bucket = f"{options.kube_cluster_name}-{options.kube_namespace}"
203
194
  deployed_pods: list[Any] = []
204
195
  worker_count: int = max(options.workers or math.ceil(options.users / USERS_PER_WORKER), 2)
196
+ os.environ["AWS_DEFAULT_REGION"] = options.region
205
197
  if options.users > 10000:
206
198
  logger.error("You asked for more than 10000 Users, that isn't allowed.")
207
199
  sys.exit(1)
@@ -217,15 +209,14 @@ def main() -> None:
217
209
  )
218
210
  sys.exit(1)
219
211
 
220
- logger.info(f"Authenticating ({options.aws_region_name}, v{__version__})")
221
- logger.debug(f"Lambda url: {options.lambda_url}")
212
+ logger.info(f"Authenticating ({os.environ['AWS_DEFAULT_REGION']}, v{__version__})")
213
+ logger.debug(f"Lambda url: {options.deployer_url}")
222
214
  credential_manager = CredentialManager(
223
- lambda_url=options.lambda_url,
215
+ lambda_url=options.deployer_url,
224
216
  access_key=options.aws_access_key_id,
225
217
  secret_key=options.aws_secret_access_key,
226
218
  username=options.username,
227
219
  password=options.password,
228
- region_name=options.aws_region_name,
229
220
  )
230
221
 
231
222
  credentials = credential_manager.get_current_credentials()
@@ -278,26 +269,23 @@ def main() -> None:
278
269
  {"name": env_variable, "value": str(os.environ[env_variable])}
279
270
  for env_variable in os.environ
280
271
  if env_variable.startswith("LOCUST_")
281
- and not env_variable.startswith("LOCUST_CLOUD")
282
272
  and not env_variable
283
273
  in [
284
274
  "LOCUST_LOCUSTFILE",
285
275
  "LOCUST_USERS",
286
276
  "LOCUST_WEB_HOST_DISPLAY_NAME",
287
- "LOCUST_API_BASE_URL",
288
277
  "LOCUST_SKIP_MONKEY_PATCH",
289
- "LOCUST_REQUIREMENTS_URL",
290
278
  ]
291
279
  and os.environ[env_variable]
292
280
  ]
293
- deploy_endpoint = f"{options.lambda_url}/{options.kube_cluster_name}"
281
+ deploy_endpoint = f"{options.deployer_url}/{options.kube_cluster_name}"
294
282
  payload = {
295
283
  "locust_args": [
296
284
  {"name": "LOCUST_LOCUSTFILE", "value": locustfile_url},
297
285
  {"name": "LOCUST_USERS", "value": str(options.users)},
298
- {"name": "LOCUST_REQUIREMENTS_URL", "value": requirements_url},
299
286
  {"name": "LOCUST_FLAGS", "value": " ".join(locust_options)},
300
- {"name": "LOCUST_API_BASE_URL", "value": DEFAULT_LAMBDA_URL},
287
+ {"name": "LOCUSTCLOUD_REQUIREMENTS_URL", "value": requirements_url},
288
+ {"name": "LOCUSTCLOUD_DEPLOYER_URL", "value": options.deployer_url},
301
289
  *locust_env_variables,
302
290
  ],
303
291
  "worker_count": worker_count,
@@ -416,7 +404,7 @@ def delete(s3_bucket, credential_manager):
416
404
  headers["AWS_SESSION_TOKEN"] = token
417
405
 
418
406
  response = requests.delete(
419
- f"{options.lambda_url}/{options.kube_cluster_name}",
407
+ f"{options.deployer_url}/{options.kube_cluster_name}",
420
408
  headers=headers,
421
409
  params={"namespace": options.kube_namespace} if options.kube_namespace else {},
422
410
  )
@@ -426,7 +414,7 @@ def delete(s3_bucket, credential_manager):
426
414
  f"Could not automatically tear down Locust Cloud: HTTP {response.status_code}/{response.reason} - Response: {response.text} - URL: {response.request.url}"
427
415
  )
428
416
  except Exception as e:
429
- logger.error(f"Could not automatically tear down Locust Cloud: {e}")
417
+ logger.error(f"Could not automatically tear down Locust Cloud: {e.__class__.__name__}:{e}")
430
418
 
431
419
  try:
432
420
  logger.debug("Cleaning up locustfiles")
locust_cloud/constants.py CHANGED
@@ -1,5 +1,5 @@
1
1
  DEFAULT_REGION_NAME = "us-east-1"
2
2
  DEFAULT_CLUSTER_NAME = "dmdb"
3
3
  DEFAULT_NAMESPACE = "default"
4
- DEFAULT_LAMBDA_URL = "https://api.locust.cloud/1"
4
+ DEFAULT_DEPLOYER_URL = "https://api.locust.cloud/1"
5
5
  USERS_PER_WORKER = 500
@@ -8,7 +8,6 @@ import jwt
8
8
  import requests
9
9
  from botocore.credentials import RefreshableCredentials
10
10
  from botocore.session import Session as BotocoreSession
11
- from locust_cloud.constants import DEFAULT_REGION_NAME
12
11
 
13
12
  logger = logging.getLogger(__name__)
14
13
 
@@ -27,7 +26,6 @@ class CredentialManager:
27
26
  password: str | None = None,
28
27
  user_sub_id: str | None = None,
29
28
  refresh_token: str | None = None,
30
- region_name: str = DEFAULT_REGION_NAME,
31
29
  access_key: str | None = None,
32
30
  secret_key: str | None = None,
33
31
  ) -> None:
@@ -36,7 +34,6 @@ class CredentialManager:
36
34
  self.password = password
37
35
  self.user_sub_id = user_sub_id
38
36
  self.refresh_token = refresh_token
39
- self.region_name = region_name
40
37
 
41
38
  self.credentials = {
42
39
  "access_key": access_key,
@@ -56,12 +53,8 @@ class CredentialManager:
56
53
  botocore_session = BotocoreSession()
57
54
  botocore_session._credentials = self.refreshable_credentials # type: ignore
58
55
  botocore_session.set_config_variable("signature_version", "v4")
59
- botocore_session.set_config_variable("region", self.region_name)
60
56
 
61
- self.session = boto3.Session(
62
- botocore_session=botocore_session,
63
- region_name=self.region_name,
64
- )
57
+ self.session = boto3.Session(botocore_session=botocore_session)
65
58
  logger.debug("Boto3 session created with RefreshableCredentials.")
66
59
 
67
60
  def obtain_credentials(self) -> None:
@@ -235,7 +235,7 @@ class Exporter:
235
235
  """
236
236
  UPDATE testruns
237
237
  SET (requests, resp_time_avg, rps_avg, fail_ratio) =
238
- (SELECT reqs, resp_time, reqs / GREATEST(duration, 1), fails / reqs) FROM
238
+ (SELECT reqs, resp_time, reqs / GREATEST(duration, 1), fails / GREATEST(reqs, 1)) FROM
239
239
  (SELECT
240
240
  COUNT(*)::numeric AS reqs,
241
241
  AVG(response_time)::numeric as resp_time
@@ -248,7 +248,7 @@ SET (requests, resp_time_avg, rps_avg, fail_ratio) =
248
248
  WHERE id = %(run_id)s""",
249
249
  {"run_id": self._run_id},
250
250
  )
251
- except psycopg.errors.DivisionByZero:
251
+ except psycopg.errors.DivisionByZero: # remove this except later, because it shouldnt happen any more
252
252
  logging.info(
253
253
  "Got DivisionByZero error when trying to update testruns, most likely because there were no requests logged"
254
254
  )
@@ -33,7 +33,7 @@ GROUP BY "name",left(exception,300)
33
33
  requests_per_second = """
34
34
  WITH request_count_agg AS (
35
35
  SELECT
36
- time_bucket_gapfill('%(resolution)ss', bucket) AS time,
36
+ time_bucket_gapfill(%(resolution)s * interval '1 second', bucket) AS time,
37
37
  COALESCE(SUM(count)/%(resolution)s, 0) as rps
38
38
  FROM requests_summary
39
39
  WHERE bucket BETWEEN %(start)s AND %(end)s
@@ -43,7 +43,7 @@ WITH request_count_agg AS (
43
43
  ),
44
44
  user_count_agg AS (
45
45
  SELECT
46
- time_bucket_gapfill('%(resolution)ss', time) AS time,
46
+ time_bucket_gapfill(%(resolution)s * interval '1 second', time) AS time,
47
47
  COALESCE(avg(user_count), 0) as users
48
48
  FROM number_of_users
49
49
  WHERE time BETWEEN %(start)s AND %(end)s
@@ -53,7 +53,7 @@ user_count_agg AS (
53
53
  ),
54
54
  errors_per_s_agg AS (
55
55
  SELECT
56
- time_bucket_gapfill('%(resolution)ss', bucket) AS time,
56
+ time_bucket_gapfill(%(resolution)s * interval '1 second', bucket) AS time,
57
57
  COALESCE(SUM(failed_count)/%(resolution)s, 0) as error_rate
58
58
  FROM requests_summary
59
59
  WHERE bucket BETWEEN %(start)s AND %(end)s
@@ -101,7 +101,7 @@ AND run_id = %(testrun)s
101
101
 
102
102
  rps_per_request = """
103
103
  SELECT
104
- time_bucket_gapfill('%(resolution)ss', bucket) AS time,
104
+ time_bucket_gapfill(%(resolution)s * interval '1 second', bucket) AS time,
105
105
  name,
106
106
  COALESCE(SUM(count)/%(resolution)s, 0) as throughput
107
107
  FROM requests_summary
@@ -114,7 +114,7 @@ ORDER BY 1,2
114
114
 
115
115
  avg_response_times = """
116
116
  SELECT
117
- time_bucket_gapfill('%(resolution)ss', bucket) as time,
117
+ time_bucket_gapfill(%(resolution)s * interval '1 second', bucket) as time,
118
118
  name,
119
119
  avg(average) as "responseTime"
120
120
  FROM requests_summary
@@ -126,7 +126,7 @@ ORDER BY 1, 2
126
126
 
127
127
  errors_per_request = """
128
128
  SELECT
129
- time_bucket_gapfill('%(resolution)ss', bucket) AS time,
129
+ time_bucket_gapfill(%(resolution)s * interval '1 second', bucket) AS time,
130
130
  name,
131
131
  SUM(failed_count)/%(resolution)s as "errorRate"
132
132
  FROM requests_summary
@@ -138,19 +138,20 @@ ORDER BY 1
138
138
 
139
139
 
140
140
  perc99_response_times = """
141
- SELECT time_bucket('%(resolution)ss', bucket) AS time,
141
+ SELECT time_bucket(%(resolution)s * interval '1 second', bucket) AS time,
142
142
  name,
143
143
  perc99
144
144
  FROM requests_summary
145
145
  WHERE bucket BETWEEN %(start)s AND %(end)s
146
146
  AND run_id = %(testrun)s
147
147
  GROUP BY 1, name, perc99
148
+ ORDER BY 1
148
149
  """
149
150
 
150
151
 
151
152
  response_length = """
152
153
  SELECT
153
- time_bucket('%(resolution)ss', bucket) as time,
154
+ time_bucket(%(resolution)s * interval '1 second', bucket) as time,
154
155
  response_length as "responseLength",
155
156
  name
156
157
  FROM requests_summary
@@ -255,6 +256,13 @@ JOIN avg_response_time_failed f ON a.time = f.time
255
256
  ORDER BY a.time
256
257
  """
257
258
 
259
+ total_runtime = """
260
+ SELECT
261
+ SUM((end_time - id) * num_users) AS "totalVuh"
262
+ FROM testruns
263
+ WHERE id >= date_trunc('month', NOW())
264
+ """
265
+
258
266
  queries: dict["str", LiteralString] = {
259
267
  "request-names": request_names,
260
268
  "requests": requests_query,
@@ -273,4 +281,5 @@ queries: dict["str", LiteralString] = {
273
281
  "testruns-table": testruns_table,
274
282
  "testruns-rps": testruns_rps,
275
283
  "testruns-response-time": testruns_response_time,
284
+ "total-runtime": total_runtime,
276
285
  }
@@ -1,6 +1,7 @@
1
1
  import logging
2
2
 
3
3
  from flask import make_response, request
4
+ from flask_login import login_required
4
5
  from locust_cloud.timescale.queries import queries
5
6
 
6
7
  logger = logging.getLogger(__name__)
@@ -12,6 +13,7 @@ def adapt_timestamp(result):
12
13
 
13
14
  def register_query(environment, pool):
14
15
  @environment.web_ui.app.route("/cloud-stats/<query>", methods=["POST"])
16
+ @login_required
15
17
  def query(query):
16
18
  results = []
17
19
  try:
@@ -21,6 +23,18 @@ def register_query(environment, pool):
21
23
  # get_conn_time = (time.perf_counter() - start_time) * 1000
22
24
  sql_params = request.get_json()
23
25
  # start_time = time.perf_counter()
26
+ from datetime import datetime, timedelta
27
+
28
+ if "start" in sql_params:
29
+ # protect the database against huge queries
30
+ start_time = datetime.fromisoformat(sql_params["start"])
31
+ end_time = datetime.fromisoformat(sql_params["end"])
32
+ if end_time >= start_time + timedelta(hours=6):
33
+ logger.warning(
34
+ f"UI asked for too long time interval. Start was {sql_params['start']}, end was {sql_params['end']}"
35
+ )
36
+ return []
37
+
24
38
  cursor = conn.execute(queries[query], sql_params)
25
39
  # exec_time = (time.perf_counter() - start_time) * 1000
26
40
  assert cursor
@@ -45,6 +59,5 @@ def register_query(environment, pool):
45
59
  logger.warning(f"Received invalid query key: '{query}'")
46
60
  return make_response("Invalid query key", 401)
47
61
  except Exception as e:
48
- print(e)
49
62
  logger.error(f"Error executing query '{query}': {e}", exc_info=True)
50
63
  return make_response("Error executing query", 401)