From 7292462b24e8b9effed3b72048f7bff5a6b0e694 Mon Sep 17 00:00:00 2001 From: Anders Aaen Springborg Date: Tue, 6 Dec 2022 13:31:14 -0800 Subject: [PATCH 1/8] add json argument to argsparser --- locust/argument_parser.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/locust/argument_parser.py b/locust/argument_parser.py index b7c3357c9d..447ebf51cc 100644 --- a/locust/argument_parser.py +++ b/locust/argument_parser.py @@ -488,6 +488,12 @@ def setup_parser_arguments(parser): help="Store HTML report to file path specified", env_var="LOCUST_HTML", ) + stats_group.add_argument( + "--json", + default=False, + action="store_true", + help="Prints the final stats in JSON format to stdout. Usefull for parsing the results in other programs/scripts." + ) log_group = parser.add_argument_group("Logging options") log_group.add_argument( From 980e61f7f8a7e94a9f61b3ce97e9a080a91c9653 Mon Sep 17 00:00:00 2001 From: Anders Aaen Springborg Date: Tue, 6 Dec 2022 13:36:27 -0800 Subject: [PATCH 2/8] Disable other logs, when outputting json --- locust/log.py | 5 +++++ locust/main.py | 7 +++++++ 2 files changed, 12 insertions(+) diff --git a/locust/log.py b/locust/log.py index 83dcc3072d..1fbe201fd8 100644 --- a/locust/log.py +++ b/locust/log.py @@ -44,6 +44,11 @@ def setup_logging(loglevel, logfile=None): "level": "INFO", "propagate": False, }, + "locust.stats_logger.json": { + "handlers": ["console_plain"], + "level": "INFO", + "propagate": False, + }, }, "root": { "handlers": ["console"], diff --git a/locust/main.py b/locust/main.py index 02f36b3618..c228d08c8c 100644 --- a/locust/main.py +++ b/locust/main.py @@ -241,6 +241,13 @@ def main(): else: stats_csv_writer = StatsCSV(environment, stats.PERCENTILES_TO_REPORT) + if options.json: + # disable all logging to stdout + for key in logging.Logger.manager.loggerDict: + if key != "locust.stats_logger.json": + logging.getLogger(key).setLevel(logging.ERROR) + + # start Web UI if not options.headless and not options.worker: # spawn web greenlet From 06e353e50de04b105955ded97e59bb2fd65ac0f6 Mon Sep 17 00:00:00 2001 From: Anders Aaen Springborg Date: Tue, 6 Dec 2022 13:53:49 -0800 Subject: [PATCH 3/8] print json to stdout, with json flag --- locust/log.py | 7 ++++++- locust/main.py | 7 ++++--- locust/stats.py | 4 ++++ 3 files changed, 14 insertions(+), 4 deletions(-) diff --git a/locust/log.py b/locust/log.py index 1fbe201fd8..7e416109e4 100644 --- a/locust/log.py +++ b/locust/log.py @@ -32,6 +32,11 @@ def setup_logging(loglevel, logfile=None): "class": "logging.StreamHandler", "formatter": "plain", }, + "console_plain_stdout": { + "class": "logging.StreamHandler", + "formatter": "plain", + "stream": "ext://sys.stdout", + }, }, "loggers": { "locust": { @@ -45,7 +50,7 @@ def setup_logging(loglevel, logfile=None): "propagate": False, }, "locust.stats_logger.json": { - "handlers": ["console_plain"], + "handlers": ["console_plain_stdout"], "level": "INFO", "propagate": False, }, diff --git a/locust/main.py b/locust/main.py index c228d08c8c..bec9aa06cd 100644 --- a/locust/main.py +++ b/locust/main.py @@ -13,7 +13,7 @@ from .env import Environment from .log import setup_logging, greenlet_exception_logger from . import stats -from .stats import print_error_report, print_percentile_stats, print_stats, stats_printer, stats_history +from .stats import print_error_report, print_percentile_stats, print_stats, print_stats_json, stats_printer, stats_history from .stats import StatsCSV, StatsCSVFileWriter from .user.inspectuser import print_task_ratio, print_task_ratio_json from .util.timespan import parse_timespan @@ -435,8 +435,9 @@ def shutdown(): logger.debug("Cleaning up runner...") if runner is not None: runner.quit() - - if not isinstance(runner, locust.runners.WorkerRunner): + if options.json: + print_stats_json(runner.stats) + elif not isinstance(runner, locust.runners.WorkerRunner): print_stats(runner.stats, current=False) print_percentile_stats(runner.stats) print_error_report(runner.stats) diff --git a/locust/stats.py b/locust/stats.py index b52d9a33bc..0296a28956 100644 --- a/locust/stats.py +++ b/locust/stats.py @@ -2,6 +2,7 @@ from abc import abstractmethod import datetime import hashlib +import json from tempfile import NamedTemporaryFile import time from collections import namedtuple, OrderedDict @@ -786,6 +787,9 @@ def print_stats(stats: RequestStats, current=True) -> None: console_logger.info(line) console_logger.info("") +def print_stats_json(stats: RequestStats) -> None: + json_logger = logging.getLogger("locust.stats_logger.json") + json_logger.info(json.dumps(stats.serialize_stats(), indent=4)) def get_stats_summary(stats: RequestStats, current=True) -> List[str]: """ From 1d60065abd1f6f4c506a2e7f9a95c1000402184c Mon Sep 17 00:00:00 2001 From: Anders Aaen Springborg Date: Tue, 6 Dec 2022 14:00:59 -0800 Subject: [PATCH 4/8] black formatting --- locust/argument_parser.py | 2 +- locust/main.py | 10 ++++++++-- locust/stats.py | 2 ++ 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/locust/argument_parser.py b/locust/argument_parser.py index 447ebf51cc..a7ed40a29e 100644 --- a/locust/argument_parser.py +++ b/locust/argument_parser.py @@ -492,7 +492,7 @@ def setup_parser_arguments(parser): "--json", default=False, action="store_true", - help="Prints the final stats in JSON format to stdout. Usefull for parsing the results in other programs/scripts." + help="Prints the final stats in JSON format to stdout. Usefull for parsing the results in other programs/scripts.", ) log_group = parser.add_argument_group("Logging options") diff --git a/locust/main.py b/locust/main.py index bec9aa06cd..50e386f1de 100644 --- a/locust/main.py +++ b/locust/main.py @@ -13,7 +13,14 @@ from .env import Environment from .log import setup_logging, greenlet_exception_logger from . import stats -from .stats import print_error_report, print_percentile_stats, print_stats, print_stats_json, stats_printer, stats_history +from .stats import ( + print_error_report, + print_percentile_stats, + print_stats, + print_stats_json, + stats_printer, + stats_history, +) from .stats import StatsCSV, StatsCSVFileWriter from .user.inspectuser import print_task_ratio, print_task_ratio_json from .util.timespan import parse_timespan @@ -246,7 +253,6 @@ def main(): for key in logging.Logger.manager.loggerDict: if key != "locust.stats_logger.json": logging.getLogger(key).setLevel(logging.ERROR) - # start Web UI if not options.headless and not options.worker: diff --git a/locust/stats.py b/locust/stats.py index 0296a28956..223d530ee5 100644 --- a/locust/stats.py +++ b/locust/stats.py @@ -787,10 +787,12 @@ def print_stats(stats: RequestStats, current=True) -> None: console_logger.info(line) console_logger.info("") + def print_stats_json(stats: RequestStats) -> None: json_logger = logging.getLogger("locust.stats_logger.json") json_logger.info(json.dumps(stats.serialize_stats(), indent=4)) + def get_stats_summary(stats: RequestStats, current=True) -> List[str]: """ stats summary will be returned as list of string From 16db8993e6bc2dde89ee69117959a9c0215bbde0 Mon Sep 17 00:00:00 2001 From: Anders Aaen Springborg Date: Sun, 25 Dec 2022 17:12:30 -0500 Subject: [PATCH 5/8] remove loggers, for printing json --- locust/log.py | 10 ---------- locust/main.py | 6 ------ locust/stats.py | 3 +-- 3 files changed, 1 insertion(+), 18 deletions(-) diff --git a/locust/log.py b/locust/log.py index 7e416109e4..83dcc3072d 100644 --- a/locust/log.py +++ b/locust/log.py @@ -32,11 +32,6 @@ def setup_logging(loglevel, logfile=None): "class": "logging.StreamHandler", "formatter": "plain", }, - "console_plain_stdout": { - "class": "logging.StreamHandler", - "formatter": "plain", - "stream": "ext://sys.stdout", - }, }, "loggers": { "locust": { @@ -49,11 +44,6 @@ def setup_logging(loglevel, logfile=None): "level": "INFO", "propagate": False, }, - "locust.stats_logger.json": { - "handlers": ["console_plain_stdout"], - "level": "INFO", - "propagate": False, - }, }, "root": { "handlers": ["console"], diff --git a/locust/main.py b/locust/main.py index 50e386f1de..9b8396ebf8 100644 --- a/locust/main.py +++ b/locust/main.py @@ -248,12 +248,6 @@ def main(): else: stats_csv_writer = StatsCSV(environment, stats.PERCENTILES_TO_REPORT) - if options.json: - # disable all logging to stdout - for key in logging.Logger.manager.loggerDict: - if key != "locust.stats_logger.json": - logging.getLogger(key).setLevel(logging.ERROR) - # start Web UI if not options.headless and not options.worker: # spawn web greenlet diff --git a/locust/stats.py b/locust/stats.py index 223d530ee5..490e132522 100644 --- a/locust/stats.py +++ b/locust/stats.py @@ -789,8 +789,7 @@ def print_stats(stats: RequestStats, current=True) -> None: def print_stats_json(stats: RequestStats) -> None: - json_logger = logging.getLogger("locust.stats_logger.json") - json_logger.info(json.dumps(stats.serialize_stats(), indent=4)) + print(json.dumps(stats.serialize_stats(), indent=4)) def get_stats_summary(stats: RequestStats, current=True) -> List[str]: From 1ee6864d5651ed94c42cd40ff781a6c562bad09e Mon Sep 17 00:00:00 2001 From: Anders Aaen Springborg Date: Sun, 25 Dec 2022 17:14:25 -0500 Subject: [PATCH 6/8] add skip log hint in help --- locust/argument_parser.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/locust/argument_parser.py b/locust/argument_parser.py index a7ed40a29e..e05129d5eb 100644 --- a/locust/argument_parser.py +++ b/locust/argument_parser.py @@ -492,7 +492,7 @@ def setup_parser_arguments(parser): "--json", default=False, action="store_true", - help="Prints the final stats in JSON format to stdout. Usefull for parsing the results in other programs/scripts.", + help="Prints the final stats in JSON format to stdout. Useful for parsing the results in other programs/scripts. Use together with --headless and --skip-log for an output only with the json data.", ) log_group = parser.add_argument_group("Logging options") From aaf164abe288d7f6ba7bb9ae2004342a28b36a46 Mon Sep 17 00:00:00 2001 From: Anders Aaen Springborg Date: Tue, 27 Dec 2022 14:46:12 -0500 Subject: [PATCH 7/8] json cli parse integration test --- locust/test/test_main.py | 39 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 38 insertions(+), 1 deletion(-) diff --git a/locust/test/test_main.py b/locust/test/test_main.py index 0b4bcca820..663fe3f7e8 100644 --- a/locust/test/test_main.py +++ b/locust/test/test_main.py @@ -1,3 +1,4 @@ +import json import os import platform import pty @@ -6,7 +7,7 @@ import textwrap from tempfile import TemporaryDirectory from unittest import TestCase -from subprocess import PIPE, STDOUT +from subprocess import PIPE, STDOUT, DEVNULL import gevent import requests @@ -1400,6 +1401,42 @@ def t(self): self.assertEqual(0, proc.returncode) self.assertEqual(0, proc_worker.returncode) + def test_json_can_be_parsed(self): + LOCUSTFILE_CONTENT = textwrap.dedent( + """ + from locust import User, task, constant + + class User1(User): + wait_time = constant(1) + + @task + def t(self): + pass + """ + ) + with mock_locustfile(content=LOCUSTFILE_CONTENT) as mocked: + proc = subprocess.Popen( + [ + "locust", + "-f", + mocked.file_path, + "--headless", + "-t", + "5s", + "--json" + ], + stderr=DEVNULL, + stdout=PIPE, + text=True, + ) + stdout, stderr = proc.communicate() + + try: + json.loads(stdout) + except json.JSONDecodeError: + self.fail(f"Trying to parse {stdout} as json failed") + self.assertEqual(0, proc.returncode) + def test_worker_indexes(self): content = """ from locust import HttpUser, task, between From a70b3b5e649c89be77321332bd9d7af3233ce362 Mon Sep 17 00:00:00 2001 From: Anders Aaen Springborg Date: Tue, 27 Dec 2022 14:46:33 -0500 Subject: [PATCH 8/8] json schema integration test --- locust/test/test_main.py | 53 +++++++++++++++++++++++++++++++++++++--- 1 file changed, 50 insertions(+), 3 deletions(-) diff --git a/locust/test/test_main.py b/locust/test/test_main.py index 663fe3f7e8..1ef19bfe6d 100644 --- a/locust/test/test_main.py +++ b/locust/test/test_main.py @@ -1414,16 +1414,49 @@ def t(self): pass """ ) + with mock_locustfile(content=LOCUSTFILE_CONTENT) as mocked: + proc = subprocess.Popen( + ["locust", "-f", mocked.file_path, "--headless", "-t", "5s", "--json"], + stderr=DEVNULL, + stdout=PIPE, + text=True, + ) + stdout, stderr = proc.communicate() + + try: + json.loads(stdout) + except json.JSONDecodeError: + self.fail(f"Trying to parse {stdout} as json failed") + self.assertEqual(0, proc.returncode) + + def test_json_schema(self): + LOCUSTFILE_CONTENT = textwrap.dedent( + """ + from locust import HttpUser, task, constant + + class QuickstartUser(HttpUser): + wait_time = constant(1) + + @task + def hello_world(self): + self.client.get("/") + + """ + ) with mock_locustfile(content=LOCUSTFILE_CONTENT) as mocked: proc = subprocess.Popen( [ "locust", "-f", mocked.file_path, + "--host", + "http://google.com", "--headless", + "-u", + "1", "-t", - "5s", - "--json" + "2s", + "--json", ], stderr=DEVNULL, stdout=PIPE, @@ -1432,11 +1465,25 @@ def t(self): stdout, stderr = proc.communicate() try: - json.loads(stdout) + data = json.loads(stdout) except json.JSONDecodeError: self.fail(f"Trying to parse {stdout} as json failed") + self.assertEqual(0, proc.returncode) + result = data[0] + self.assertEqual(float, type(result["last_request_timestamp"])) + self.assertEqual(float, type(result["start_time"])) + self.assertEqual(int, type(result["num_requests"])) + self.assertEqual(int, type(result["num_none_requests"])) + self.assertEqual(float, type(result["total_response_time"])) + self.assertEqual(float, type(result["max_response_time"])) + self.assertEqual(float, type(result["min_response_time"])) + self.assertEqual(int, type(result["total_content_length"])) + self.assertEqual(dict, type(result["response_times"])) + self.assertEqual(dict, type(result["num_reqs_per_sec"])) + self.assertEqual(dict, type(result["num_fail_per_sec"])) + def test_worker_indexes(self): content = """ from locust import HttpUser, task, between