locust-cloud 1.6.0__py3-none-any.whl → 1.8.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,34 +6,29 @@ __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
19
20
  from psycopg_pool import ConnectionPool
20
21
 
21
- PG_USER = os.environ.get("PG_USER")
22
- PG_HOST = os.environ.get("PG_HOST")
23
- PG_PASSWORD = os.environ.get("PG_PASSWORD")
24
- PG_DATABASE = os.environ.get("PG_DATABASE")
25
- PG_PORT = os.environ.get("PG_PORT", 5432)
26
22
  GRAPH_VIEWER = os.environ.get("GRAPH_VIEWER")
27
- MAX_USER_COUNT = os.environ.get("MAX_USER_COUNT")
28
23
  logger = logging.getLogger(__name__)
29
24
 
30
25
 
31
26
  @events.init_command_line_parser.add_listener
32
27
  def add_arguments(parser: LocustArgumentParser):
33
- if not (PG_HOST or GRAPH_VIEWER):
28
+ if not (os.environ.get("PGHOST") or GRAPH_VIEWER):
34
29
  parser.add_argument_group(
35
30
  "locust-cloud",
36
- "locust-cloud disabled, because PG_HOST was not set - this is normal for local runs",
31
+ "locust-cloud disabled, because PGHOST was not set - this is normal for local runs",
37
32
  )
38
33
  return
39
34
 
@@ -56,61 +51,48 @@ def add_arguments(parser: LocustArgumentParser):
56
51
  default="",
57
52
  help="Description of the test being run",
58
53
  )
54
+ # do not set
55
+ # used for sending the run id from master to workers
56
+ locust_cloud.add_argument(
57
+ "--run-id",
58
+ type=str,
59
+ env_var="LOCUSTCLOUD_RUN_ID",
60
+ help=configargparse.SUPPRESS,
61
+ )
59
62
 
60
63
 
61
64
  def set_autocommit(conn: psycopg.Connection):
62
65
  conn.autocommit = True
63
66
 
64
67
 
65
- def create_connection_pool(
66
- pg_user: str, pg_host: str, pg_password: str, pg_database: str, pg_port: str | int
67
- ) -> ConnectionPool:
68
+ @events.init.add_listener
69
+ def on_locust_init(environment: locust.env.Environment, **_args):
70
+ if not (os.environ.get("PGHOST")):
71
+ return
72
+
68
73
  try:
69
74
  conninfo = make_conninfo(
70
- dbname=pg_database,
71
- user=pg_user,
72
- port=pg_port,
73
- password=pg_password,
74
- host=pg_host,
75
75
  sslmode="require",
76
- options="-c statement_timeout=55000",
77
76
  )
78
- return ConnectionPool(
77
+ pool = ConnectionPool(
79
78
  conninfo,
80
79
  min_size=1,
81
- max_size=10,
80
+ max_size=20,
82
81
  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,
82
+ check=ConnectionPool.check_connection,
101
83
  )
102
84
  pool.wait()
103
85
  except Exception as e:
104
86
  logger.exception(e)
105
- logger.error(f"{PG_HOST=}")
106
87
  raise
107
88
 
89
+ if not GRAPH_VIEWER:
90
+ IdleExit(environment)
91
+
108
92
  if not GRAPH_VIEWER and environment.parsed_options and environment.parsed_options.exporter:
109
93
  Exporter(environment, pool)
110
94
 
111
95
  if environment.web_ui:
112
- environment.web_ui.template_args["maxUserCount"] = MAX_USER_COUNT
113
-
114
96
  if GRAPH_VIEWER:
115
97
  environment.web_ui.template_args["isGraphViewer"] = True
116
98
 
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
@@ -199,12 +199,7 @@ def main() -> None:
199
199
  deployments: list[Any] = []
200
200
  worker_count: int = max(options.workers or math.ceil(options.users / USERS_PER_WORKER), 2)
201
201
  os.environ["AWS_DEFAULT_REGION"] = options.region
202
- if options.users > 5000000:
203
- logger.error("You asked for more than 5000000 Users, that isn't allowed.")
204
- sys.exit(1)
205
- if worker_count > 1000:
206
- logger.error("You asked for more than 20 workers, that isn't allowed.")
207
- sys.exit(1)
202
+
208
203
  try:
209
204
  if not (
210
205
  (options.username and options.password) or (options.aws_access_key_id and options.aws_secret_access_key)
@@ -295,6 +290,7 @@ def main() -> None:
295
290
  *locust_env_variables,
296
291
  ],
297
292
  "worker_count": worker_count,
293
+ "user_count": options.users,
298
294
  "image_tag": options.image_tag,
299
295
  }
300
296
  headers = {
@@ -372,6 +368,7 @@ def main() -> None:
372
368
  startTime=timestamp,
373
369
  startFromHead=True,
374
370
  )
371
+ locust_shutdown = False
375
372
  for event in response.get("events", []):
376
373
  message = event.get("message", "")
377
374
  event_timestamp = event.get("timestamp", timestamp) + 1
@@ -379,9 +376,17 @@ def main() -> None:
379
376
  message_json = json.loads(message)
380
377
  if "log" in message_json:
381
378
  print(message_json["log"])
379
+
380
+ if "Shutting down (exit code" in message_json["log"]:
381
+ locust_shutdown = True
382
+
382
383
  except json.JSONDecodeError:
383
384
  print(message)
384
385
  timestamp = event_timestamp
386
+
387
+ if locust_shutdown:
388
+ break
389
+
385
390
  time.sleep(5)
386
391
  except ClientError as e:
387
392
  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,15 +22,23 @@ 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
41
+ self._has_logged_test_stop = False
34
42
  self._pid = os.getpid()
35
43
  self.pool = pool
36
44
 
@@ -43,13 +51,6 @@ class Exporter:
43
51
  events.spawning_complete.add_listener(self.spawning_complete)
44
52
  atexit.register(self.log_stop_test_run)
45
53
 
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
54
  def on_cpu_warning(self, environment: locust.env.Environment, cpu_usage, message=None, timestamp=None, **kwargs):
54
55
  # passing a custom message & timestamp to the event is a haxx to allow using this event for reporting generic events
55
56
  if not timestamp:
@@ -58,18 +59,20 @@ class Exporter:
58
59
  message = f"High CPU usage ({cpu_usage}%)"
59
60
  with self.pool.connection() as conn:
60
61
  conn.execute(
61
- "INSERT INTO events (time, text, run_id) VALUES (%s, %s, %s)", (timestamp, message, self._run_id)
62
+ "INSERT INTO events (time, text, run_id, customer) VALUES (%s, %s, %s, current_user)",
63
+ (timestamp, message, self._run_id),
62
64
  )
63
65
 
64
66
  def on_test_start(self, environment: locust.env.Environment):
65
67
  if not self.env.parsed_options or not self.env.parsed_options.worker:
68
+ self._has_logged_test_stop = False
66
69
  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)
70
+ self.env.parsed_options.run_id = format_datetime(environment._run_id) # type: ignore
71
71
  self.log_start_testrun()
72
72
  self._user_count_logger = gevent.spawn(self._log_user_count)
73
+ self._update_end_time_task = gevent.spawn(self._update_end_time)
74
+ if self.env.parsed_options.worker:
75
+ self._run_id = parse_datetime(self.env.parsed_options.run_id)
73
76
 
74
77
  def _log_user_count(self):
75
78
  while True:
@@ -78,7 +81,7 @@ class Exporter:
78
81
  try:
79
82
  with self.pool.connection() as conn:
80
83
  conn.execute(
81
- """INSERT INTO number_of_users(time, run_id, user_count) VALUES (%s, %s, %s)""",
84
+ """INSERT INTO number_of_users(time, run_id, user_count, customer) VALUES (%s, %s, %s, current_user)""",
82
85
  (datetime.now(UTC).isoformat(), self._run_id, self.env.runner.user_count),
83
86
  )
84
87
  except psycopg.Error as error:
@@ -99,6 +102,10 @@ class Exporter:
99
102
  gevent.sleep(0.5)
100
103
 
101
104
  def _update_end_time(self):
105
+ # delay setting first end time
106
+ # so UI doesn't display temporary value
107
+ gevent.sleep(5)
108
+
102
109
  # Regularly update endtime to prevent missing endtimes when a test crashes
103
110
  while True:
104
111
  current_end_time = datetime.now(UTC)
@@ -117,35 +124,39 @@ class Exporter:
117
124
  try:
118
125
  with self.pool.connection() as conn:
119
126
  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
- )
127
+ with conn.cursor().copy(
128
+ "COPY requests (time,run_id,greenlet_id,loadgen,name,request_type,response_time,success,response_length,exception,pid,url,context) FROM STDIN"
129
+ ) as copy:
130
+ for sample in samples:
131
+ copy.write_row(sample)
128
132
  except psycopg.Error as error:
129
133
  logging.error("Failed to write samples to Postgresql timescale database: " + repr(error))
130
134
 
131
135
  def on_test_stop(self, environment):
136
+ if getattr(self, "_update_end_time_task", False):
137
+ self._update_end_time_task.kill()
132
138
  if getattr(self, "_user_count_logger", False):
133
139
  self._user_count_logger.kill()
134
140
  with self.pool.connection() as conn:
135
141
  conn.execute(
136
- """INSERT INTO number_of_users(time, run_id, user_count) VALUES (%s, %s, %s)""",
142
+ """INSERT INTO number_of_users(time, run_id, user_count, customer) VALUES (%s, %s, %s, current_user)""",
137
143
  (datetime.now(UTC).isoformat(), self._run_id, 0),
138
144
  )
139
145
  self.log_stop_test_run()
146
+ self._has_logged_test_stop = True
140
147
 
141
148
  def on_quit(self, exit_code, **kwargs):
142
149
  self._finished = True
143
150
  atexit._clear() # make sure we dont capture additional ctrl-c:s
144
151
  self._background.join(timeout=10)
145
- self._update_end_time_task.kill()
152
+ if getattr(self, "_update_end_time_task", False):
153
+ self._update_end_time_task.kill()
146
154
  if getattr(self, "_user_count_logger", False):
147
155
  self._user_count_logger.kill()
148
- self.log_stop_test_run(exit_code)
156
+ if not self._has_logged_test_stop:
157
+ self.log_stop_test_run()
158
+ if not self.env.parsed_options.worker:
159
+ self.log_exit_code(exit_code)
149
160
 
150
161
  def on_request(
151
162
  self,
@@ -159,6 +170,9 @@ class Exporter:
159
170
  url=None,
160
171
  **kwargs,
161
172
  ):
173
+ # handle if a worker connects after test_start
174
+ if not self._run_id:
175
+ self._run_id = parse_datetime(self.env.parsed_options.run_id)
162
176
  success = 0 if exception else 1
163
177
  if start_time:
164
178
  time = datetime.fromtimestamp(start_time, tz=UTC)
@@ -167,35 +181,35 @@ class Exporter:
167
181
  # (which will be horribly wrong if users spend a lot of time in a with/catch_response-block)
168
182
  time = datetime.now(UTC) - timedelta(milliseconds=response_time or 0)
169
183
  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
-
184
- if response_length >= 0:
185
- sample["response_length"] = response_length
186
- else:
187
- sample["response_length"] = None
188
184
 
189
185
  if exception:
190
186
  if isinstance(exception, CatchResponseError):
191
- sample["exception"] = str(exception)
187
+ exception = str(exception)
192
188
  else:
193
189
  try:
194
- sample["exception"] = repr(exception)
190
+ exception = repr(exception)
195
191
  except AttributeError:
196
- sample["exception"] = f"{exception.__class__} (and it has no string representation)"
192
+ exception = f"{exception.__class__} (and it has no string representation)"
193
+
194
+ exception = exception[:300]
197
195
  else:
198
- sample["exception"] = None
196
+ exception = None
197
+
198
+ sample = (
199
+ time,
200
+ self._run_id,
201
+ greenlet_id,
202
+ self._hostname,
203
+ name,
204
+ request_type,
205
+ response_time,
206
+ success,
207
+ response_length,
208
+ exception,
209
+ self._pid,
210
+ url[0:255] if url else None,
211
+ psycopg.types.json.Json(context, safe_serialize),
212
+ )
199
213
 
200
214
  self._samples.append(sample)
201
215
 
@@ -203,7 +217,7 @@ class Exporter:
203
217
  cmd = sys.argv[1:]
204
218
  with self.pool.connection() as conn:
205
219
  conn.execute(
206
- "INSERT INTO testruns (id, num_users, worker_count, username, locustfile, description, arguments) VALUES (%s,%s,%s,%s,%s,%s,%s)",
220
+ "INSERT INTO testruns (id, num_users, worker_count, username, locustfile, description, arguments, customer) VALUES (%s,%s,%s,%s,%s,%s,%s,current_user)",
207
221
  (
208
222
  self._run_id,
209
223
  self.env.runner.target_user_count if self.env.runner else 1,
@@ -220,7 +234,7 @@ class Exporter:
220
234
  ),
221
235
  )
222
236
  conn.execute(
223
- "INSERT INTO events (time, text, run_id) VALUES (%s, %s, %s)",
237
+ "INSERT INTO events (time, text, run_id, customer) VALUES (%s, %s, %s, current_user)",
224
238
  (datetime.now(UTC).isoformat(), "Test run started", self._run_id),
225
239
  )
226
240
 
@@ -230,7 +244,7 @@ class Exporter:
230
244
  try:
231
245
  with self.pool.connection() as conn:
232
246
  conn.execute(
233
- "INSERT INTO events (time, text, run_id) VALUES (%s, %s, %s)",
247
+ "INSERT INTO events (time, text, run_id, customer) VALUES (%s, %s, %s, current_user)",
234
248
  (end_time, f"Rampup complete, {user_count} users spawned", self._run_id),
235
249
  )
236
250
  except psycopg.Error as error:
@@ -238,7 +252,7 @@ class Exporter:
238
252
  "Failed to insert rampup complete event time to Postgresql timescale database: " + repr(error)
239
253
  )
240
254
 
241
- def log_stop_test_run(self, exit_code=None):
255
+ def log_stop_test_run(self):
242
256
  logging.debug(f"Test run id {self._run_id} stopping")
243
257
  if self.env.parsed_options.worker:
244
258
  return # only run on master or standalone
@@ -246,17 +260,14 @@ class Exporter:
246
260
  try:
247
261
  with self.pool.connection() as conn:
248
262
  conn.execute(
249
- "UPDATE testruns SET end_time = %s, exit_code = %s where id = %s",
250
- (end_time, exit_code, self._run_id),
263
+ "UPDATE testruns SET end_time = %s WHERE id = %s",
264
+ (end_time, self._run_id),
251
265
  )
252
- conn.execute(
253
- "INSERT INTO events (time, text, run_id) VALUES (%s, %s, %s)",
254
- (end_time, f"Finished with exit code: {exit_code}", self._run_id),
255
- )
256
- # The AND time > run_id clause in the following statements are there to help Timescale performance
257
- # We dont use start_time / end_time to calculate RPS, instead we use the time between the actual first and last request
258
- # (as this is a more accurate measurement of the actual test)
266
+
259
267
  try:
268
+ # The AND time > run_id clause in the following statements are there to help Timescale performance
269
+ # We dont use start_time / end_time to calculate RPS, instead we use the time between the actual first and last request
270
+ # (as this is a more accurate measurement of the actual test)
260
271
  conn.execute(
261
272
  """
262
273
  UPDATE testruns
@@ -265,12 +276,12 @@ SET (requests, resp_time_avg, rps_avg, fail_ratio) =
265
276
  (SELECT
266
277
  COUNT(*)::numeric AS reqs,
267
278
  AVG(response_time)::numeric as resp_time
268
- FROM requests WHERE run_id = %(run_id)s AND time > %(run_id)s) AS _,
279
+ FROM requests_view WHERE run_id = %(run_id)s AND time > %(run_id)s) AS _,
269
280
  (SELECT
270
- EXTRACT(epoch FROM (SELECT MAX(time)-MIN(time) FROM requests WHERE run_id = %(run_id)s AND time > %(run_id)s))::numeric AS duration) AS __,
281
+ EXTRACT(epoch FROM (SELECT MAX(time)-MIN(time) FROM requests_view WHERE run_id = %(run_id)s AND time > %(run_id)s))::numeric AS duration) AS __,
271
282
  (SELECT
272
283
  COUNT(*)::numeric AS fails
273
- FROM requests WHERE run_id = %(run_id)s AND time > %(run_id)s AND success = 0) AS ___
284
+ FROM requests_view WHERE run_id = %(run_id)s AND time > %(run_id)s AND success = 0) AS ___
274
285
  WHERE id = %(run_id)s""",
275
286
  {"run_id": self._run_id},
276
287
  )
@@ -283,3 +294,20 @@ WHERE id = %(run_id)s""",
283
294
  "Failed to update testruns record (or events) with end time to Postgresql timescale database: "
284
295
  + repr(error)
285
296
  )
297
+
298
+ def log_exit_code(self, exit_code=None):
299
+ try:
300
+ with self.pool.connection() as conn:
301
+ conn.execute(
302
+ "UPDATE testruns SET exit_code = %s WHERE id = %s",
303
+ (exit_code, self._run_id),
304
+ )
305
+ conn.execute(
306
+ "INSERT INTO events (time, text, run_id, customer) VALUES (%s, %s, %s, current_user)",
307
+ (datetime.now(UTC).isoformat(), f"Finished with exit code: {exit_code}", self._run_id),
308
+ )
309
+ except psycopg.Error as error:
310
+ logging.error(
311
+ "Failed to update testruns record (or events) with end time to Postgresql timescale database: "
312
+ + repr(error)
313
+ )