diff --git a/.travis.yml b/.travis.yml index 79606a97f7..a0cb20f3d7 100644 --- a/.travis.yml +++ b/.travis.yml @@ -15,7 +15,7 @@ matrix: addons: apt: packages: - - libevent-dev + - libev-dev install: - pip install tox script: diff --git a/docs/changelog.rst b/docs/changelog.rst index f97d6f07c5..0a7fc5e713 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -2,18 +2,27 @@ Changelog ########## -0.8a2 +0.8.1 ===== -.. note:: +* Updated pyzmq version, and changed so that we don't pin a specific version. + This makes it easier to install Locust on Windows. - Locust 0.8 only exists as a pre-release on PyPI, and can be installed using: pip install locustio==0.8a2 +0.8 +=== * Support Python 3 +* Dropped support for Python 2.6 * Added `--no-reset-stats` option for controling if the statistics should be reset once the hatching is complete -* Added charts, for RPS and average response time, to the web UI +* Added charts to the web UI for requests per second, average response time, and number of + simulated users. +* Updated the design of the web UI. +* Added ability to write a CSV file for results via command line flag +* Added the URL of the host that is currently being tested to the web UI. +* We now also apply gevent's monkey patching of threads. This fixes an issue when + using Locust to test Cassandra (https://github.com/locustio/locust/issues/569). * Various bug fixes and improvements diff --git a/docs/conf.py b/docs/conf.py index a933a8ebde..901be34b28 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -10,6 +10,12 @@ import os +# The default replacements for |version| and |release|, also used in various +# other places throughout the built documents. +# +# The short X.Y version. +from locust import __version__ + # General configuration # --------------------- @@ -38,11 +44,6 @@ 'requests': ('http://requests.readthedocs.org/en/latest/', None), } -# The default replacements for |version| and |release|, also used in various -# other places throughout the built documents. -# -# The short X.Y version. -from locust import __version__ # The full version, including alpha/beta/rc tags. release = __version__ diff --git a/docs/installation.rst b/docs/installation.rst index 52d286cbf3..43a594c3f7 100644 --- a/docs/installation.rst +++ b/docs/installation.rst @@ -40,12 +40,19 @@ or:: Installing Locust on Windows ---------------------------- -The easiest way to get Locust running on Windows is to first install pre built binary packages for -gevent and greenlet and then follow the above instructions. +On Windows, running ``pip install locustio`` might fail depedning on if you have a build environment +set up correctly. In that case, the easiest way to get Locust running on windows is to first install +the pre built binary package for pyzmq (and possibly for gevent and greenlet as well). You can find an unofficial collection of pre built python packages for windows here: `http://www.lfd.uci.edu/~gohlke/pythonlibs/ `_ +When you've downloaded a pre-built ``.whl`` file, you can install it with:: + + pip install pyzmq‑16.0.2‑cp36‑cp36m‑win32.whl + +Once you've done that you should be able to just ``pip install locustio``. + .. note:: Running Locust on Windows should work fine for developing and testing your load testing @@ -59,9 +66,9 @@ Installing Locust on OS X The following is currently the shortest path to installing gevent on OS X using Homebrew. #. Install `Homebrew `_. -#. Install libevent (dependency for gevent):: +#. Install libev (dependency for gevent):: - brew install libevent + brew install libev #. Then follow the above instructions. diff --git a/docs/quickstart.rst b/docs/quickstart.rst index a4ce4217ca..f57242364d 100644 --- a/docs/quickstart.rst +++ b/docs/quickstart.rst @@ -101,6 +101,20 @@ host defaults to 127.0.0.1):: locust -f locust_files/my_locust_file.py --slave --master-host=192.168.0.100 --host=http://example.com +You may wish to consume your Locust results via a csv file. In this case, there are two ways to do this. + +First, when running the webserver, you can retrieve a csv from ``localhost:8089/stats/requests/csv`` and ``localhost:8089/stats/distribution/csv``. +Second you can run Locust with a flag which will periodically save the csv file. This is particularly useful +if you plan on running Locust in an automated way with the ``--no-web`` flag:: + + locust -f locust_files/my_locust_file.py --csv=foobar --no-web -n10 -c1 + +You can also customize how frequently this is written if you desire faster (or slower) writing:: + + + import locust.stats + locust.stats.CSV_STATS_INTERVAL_SEC = 5 # default is 2 seconds + .. note:: To see all available options type:: diff --git a/examples/basic.py b/examples/basic.py index 8850aeb498..c34610e8df 100644 --- a/examples/basic.py +++ b/examples/basic.py @@ -1,5 +1,6 @@ from locust import HttpLocust, TaskSet, task + def index(l): l.client.get("/") diff --git a/examples/browse_docs_test.py b/examples/browse_docs_test.py new file mode 100644 index 0000000000..236c73ba4b --- /dev/null +++ b/examples/browse_docs_test.py @@ -0,0 +1,49 @@ +# This locust test script example will simulate a user +# browsing the Locust documentation on http://docs.locust.io + +import random +from locust import HttpLocust, TaskSet, task +from pyquery import PyQuery + + +class BrowseDocumentation(TaskSet): + def on_start(self): + # assume all users arrive at the index page + self.index_page() + self.urls_on_current_page = self.toc_urls + + @task(10) + def index_page(self): + r = self.client.get("/") + pq = PyQuery(r.content) + link_elements = pq(".toctree-wrapper a.internal") + self.toc_urls = [ + l.attrib["href"] for l in link_elements + ] + + @task(50) + def load_page(self, url=None): + url = random.choice(self.toc_urls) + r = self.client.get(url) + pq = PyQuery(r.content) + link_elements = pq("a.internal") + self.urls_on_current_page = [ + l.attrib["href"] for l in link_elements + ] + + @task(30) + def load_sub_page(self): + url = random.choice(self.urls_on_current_page) + r = self.client.get(url) + + +class AwesomeUser(HttpLocust): + task_set = BrowseDocumentation + host = "http://docs.locust.io/en/latest/" + + # we assume someone who is browsing the Locust docs, + # generally has a quite long waiting time (between + # 20 and 600 seconds), since there's a bunch of text + # on each page + min_wait = 20 * 1000 + max_wait = 600 * 1000 diff --git a/examples/custom_xmlrpc_client/server.py b/examples/custom_xmlrpc_client/server.py index 536c5852fa..84cd035fe7 100644 --- a/examples/custom_xmlrpc_client/server.py +++ b/examples/custom_xmlrpc_client/server.py @@ -1,7 +1,8 @@ -import time import random +import time from SimpleXMLRPCServer import SimpleXMLRPCServer + def get_time(): time.sleep(random.random()) return time.time() diff --git a/examples/custom_xmlrpc_client/xmlrpc_locustfile.py b/examples/custom_xmlrpc_client/xmlrpc_locustfile.py index 21e78aa840..f117acd779 100644 --- a/examples/custom_xmlrpc_client/xmlrpc_locustfile.py +++ b/examples/custom_xmlrpc_client/xmlrpc_locustfile.py @@ -1,7 +1,7 @@ import time import xmlrpclib -from locust import Locust, events, task, TaskSet +from locust import Locust, TaskSet, events, task class XmlRpcClient(xmlrpclib.ServerProxy): diff --git a/examples/dynamice_user_credentials.py b/examples/dynamice_user_credentials.py new file mode 100644 index 0000000000..6f8f66baa5 --- /dev/null +++ b/examples/dynamice_user_credentials.py @@ -0,0 +1,25 @@ +# locustfile.py + +from locust import HttpLocust, TaskSet, task + +USER_CREDENTIALS = [ + ("user1", "password"), + ("user2", "password"), + ("user3", "password"), +] + +class UserBehaviour(TaskSet): + def on_start(self): + if len(USER_CREDENTIALS) > 0: + user, passw = USER_CREDENTIALS.pop() + self.client.post("/login", {"username":user, "password":passw}) + + @task + def some_task(self): + # user should be logged in here (unless the USER_CREDENTIALS ran out) + self.client.get("/protected/resource") + +class User(HttpLocust): + task_set = UserBehaviour + min_wait = 5000 + max_wait = 60000 diff --git a/examples/events.py b/examples/events.py index be388d0c0d..7b1de7fafe 100644 --- a/examples/events.py +++ b/examples/events.py @@ -1,11 +1,12 @@ -# encoding: utf-8 +# -*- coding: utf-8 -*- """ This is an example of a locustfile that uses Locust's built in event hooks to track the sum of the content-length header in all successful HTTP responses """ -from locust import HttpLocust, TaskSet, task, events, web +from locust import HttpLocust, TaskSet, events, task, web + class MyTaskSet(TaskSet): @task(2) diff --git a/examples/multiple_hosts.py b/examples/multiple_hosts.py new file mode 100644 index 0000000000..b30585b37c --- /dev/null +++ b/examples/multiple_hosts.py @@ -0,0 +1,31 @@ +import os + +from locust import HttpLocust, TaskSet, task +from locust.clients import HttpSession + +class MultipleHostsLocust(HttpLocust): + abstract = True + + def __init__(self, *args, **kwargs): + super(MultipleHostsLocust, self).__init__(*args, **kwargs) + self.api_client = HttpSession(base_url=os.environ["API_HOST"]) + + +class UserTasks(TaskSet): + # but it might be convenient to use the @task decorator + @task + def index(self): + self.locust.client.get("/") + + @task + def index_other_host(self): + self.locust.api_client.get("/stats/requests") + +class WebsiteUser(MultipleHostsLocust): + """ + Locust user class that does requests to the locust web server running on localhost + """ + host = "http://127.0.0.1:8089" + min_wait = 2000 + max_wait = 5000 + task_set = UserTasks diff --git a/examples/semaphore_wait.py b/examples/semaphore_wait.py new file mode 100644 index 0000000000..563b89c95d --- /dev/null +++ b/examples/semaphore_wait.py @@ -0,0 +1,25 @@ +from locust import HttpLocust, TaskSet, task, events + +from gevent.coros import Semaphore +all_locusts_spawned = Semaphore() +all_locusts_spawned.acquire() + +def on_hatch_complete(**kw): + all_locusts_spawned.release() + +events.hatch_complete += on_hatch_complete + +class UserTasks(TaskSet): + def on_start(self): + all_locusts_spawned.wait() + self.wait() + + @task + def index(self): + self.client.get("/") + +class WebsiteUser(HttpLocust): + host = "http://127.0.0.1:8089" + min_wait = 2000 + max_wait = 5000 + task_set = UserTasks diff --git a/examples/vagrant/vagrant.sh b/examples/vagrant/vagrant.sh index 224f4d5230..62c6e2a912 100644 --- a/examples/vagrant/vagrant.sh +++ b/examples/vagrant/vagrant.sh @@ -6,7 +6,7 @@ # Update and install some dependencies apt-get -y update -apt-get -y install build-essential python-pip python-dev libevent-dev libev-dev libzmq-dev +apt-get -y install build-essential python-pip python-dev libev-dev libzmq-dev cd /vagrant pip install --use-mirrors pyzmq supervisor diff --git a/locust/__init__.py b/locust/__init__.py index 525fdadb5b..7fd2112325 100644 --- a/locust/__init__.py +++ b/locust/__init__.py @@ -1,4 +1,4 @@ from .core import HttpLocust, Locust, TaskSet, task from .exception import InterruptTaskSet, ResponseError, RescheduleTaskImmediately -__version__ = "0.8a2" +__version__ = "0.8.1" diff --git a/locust/cache.py b/locust/cache.py index 3b5a4d3419..4852b1d8bc 100644 --- a/locust/cache.py +++ b/locust/cache.py @@ -1,5 +1,6 @@ from time import time + def memoize(timeout, dynamic_timeout=False): """ Memoization decorator with support for timeout. @@ -28,4 +29,3 @@ def clear_cache(): wrapper.clear_cache = clear_cache return wrapper return decorator - diff --git a/locust/clients.py b/locust/clients.py index 63cad5237c..8a31d957e2 100644 --- a/locust/clients.py +++ b/locust/clients.py @@ -1,13 +1,14 @@ import re import time -from six.moves.urllib.parse import urlparse, urlunparse -import six import requests -from requests import Response, Request +import six +from requests import Request, Response from requests.auth import HTTPBasicAuth -from requests.exceptions import (RequestException, MissingSchema, - InvalidSchema, InvalidURL) +from requests.exceptions import (InvalidSchema, InvalidURL, MissingSchema, + RequestException) + +from six.moves.urllib.parse import urlparse, urlunparse from . import events from .exception import CatchResponseError, ResponseError diff --git a/locust/configuration.py b/locust/configuration.py index 4ab288dbd8..0eef6d1396 100644 --- a/locust/configuration.py +++ b/locust/configuration.py @@ -12,16 +12,24 @@ class ClientConfiguration: This class is a handler for data configuration with JSON data structure. """ - config_data = None + def __init__(self): + self.config_data = None - def read_json(self): + def read_json(self, path=None): """ Will get the data of configuration as JSON. It reads configuration file once. """ if self.config_data is None: + if path is None: + path = CONFIG_PATH + else : + if path.startswith('./') : + path = path[1:] + elif not path.startswith('/'): + path = '/%s' % (path) try: - with open((os.environ['PYTHONPATH'].split(os.pathsep))[-1] + CONFIG_PATH, "r") as data_file: + with open((os.environ['PYTHONPATH'].split(os.pathsep))[-1] + path, "r") as data_file: self.config_data = json.load(data_file) except Exception as err: logger.info(err) diff --git a/locust/core.py b/locust/core.py index fd75737334..0520fa3237 100644 --- a/locust/core.py +++ b/locust/core.py @@ -1,22 +1,27 @@ +import logging +import random +import sys +import traceback +from time import time + import gevent from . import runners from gevent import monkey, GreenletExit import six -from six.moves import xrange -monkey.patch_all(thread=False) +from gevent import GreenletExit, monkey +from six.moves import xrange -from time import time -import sys -import random -import traceback -import logging +# The monkey patching must run before requests is imported, or else +# we'll get an infinite recursion when doing SSL/HTTPS requests. +# See: https://github.com/requests/requests/issues/3752#issuecomment-294608002 +monkey.patch_all() -from .clients import HttpSession from . import events +from .clients import HttpSession from .configuration import ClientConfiguration - -from .exception import LocustError, InterruptTaskSet, RescheduleTask, RescheduleTaskImmediately, StopLocust +from .exception import (InterruptTaskSet, LocustError, RescheduleTask, + RescheduleTaskImmediately, StopLocust) logger = logging.getLogger(__name__) @@ -356,7 +361,7 @@ def client(self): """ Reference to the :py:attr:`client ` attribute of the root Locust instance. - """ + """ return self.locust.client @property @@ -365,6 +370,13 @@ def configuration(self): Reference to configuration.py """ return self.config.read_json() + + def team_configuration(self, path): + """ + Reference to get_team_config func in configuration.py + """ + config = ClientConfiguration() + return config.read_json(path) @property def runners(self): diff --git a/locust/inspectlocust.py b/locust/inspectlocust.py index 15555341e8..51ab1e9184 100644 --- a/locust/inspectlocust.py +++ b/locust/inspectlocust.py @@ -1,9 +1,11 @@ import inspect + import six from .core import Locust, TaskSet from .log import console_logger + def print_task_ratio(locusts, total=False, level=0, parent_ratio=1.0): d = get_task_ratio_dict(locusts, total=total, parent_ratio=parent_ratio) _print_task_ratio(d) diff --git a/locust/log.py b/locust/log.py index 24ffffbef6..5c824f5c18 100644 --- a/locust/log.py +++ b/locust/log.py @@ -1,6 +1,6 @@ import logging -import sys import socket +import sys host = socket.gethostname() diff --git a/locust/main.py b/locust/main.py index 98c2599f99..f6dc1bcb1c 100644 --- a/locust/main.py +++ b/locust/main.py @@ -1,26 +1,25 @@ -import locust -from . import runners - -import imp -import gevent -import sys -import os -import signal import inspect import logging +import os +import signal import socket +import sys import time import tests_loader import fileio from optparse import OptionParser -from . import web -from .log import setup_logging, console_logger -from .stats import stats_printer, print_percentile_stats, print_error_report, print_stats -from .inspectlocust import print_task_ratio, get_task_ratio_dict -from .core import Locust, HttpLocust -from .runners import MasterLocustRunner, SlaveLocustRunner, LocalLocustRunner -from . import events +import gevent + +import locust + +from . import events, runners, web +from .core import HttpLocust, Locust +from .inspectlocust import get_task_ratio_dict, print_task_ratio +from .log import console_logger, setup_logging +from .runners import LocalLocustRunner, MasterLocustRunner, SlaveLocustRunner +from .stats import (print_error_report, print_percentile_stats, print_stats, + stats_printer, stats_writer, write_stat_csvs) _internals = [Locust, HttpLocust] version = locust.__version__ @@ -48,7 +47,7 @@ def parse_options(): default="", help="Host to bind the web interface to. Defaults to '' (all interfaces)" ) - + parser.add_option( '-P', '--port', '--web-port', type="int", @@ -56,7 +55,7 @@ def parse_options(): default=8089, help="Port on which to run web host" ) - + parser.add_option( '-f', '--locustfile', dest='locustfile', @@ -64,6 +63,16 @@ def parse_options(): help="Python module file to import, e.g. '../other.py'. Default: locustfile" ) + # A file that contains the current request stats. + parser.add_option( + '--csv', '--csv-base-name', + action='store', + type='str', + dest='csvfilebase', + default=None, + help="Store current request stats to files in CSV format.", + ) + # if locust should be run in distributed mode as master parser.add_option( '--master', @@ -81,7 +90,7 @@ def parse_options(): default=False, help="Set locust to run in distributed mode with this process as slave" ) - + # master host options parser.add_option( '--master-host', @@ -91,7 +100,7 @@ def parse_options(): default="127.0.0.1", help="Host or IP address of locust master for distributed load testing. Only used when running with --slave. Defaults to 127.0.0.1." ) - + parser.add_option( '--master-port', action='store', @@ -109,7 +118,7 @@ def parse_options(): default="*", help="Interfaces (hostname, ip) that locust master should bind to. Only used when running with --master. Defaults to * (all available interfaces)." ) - + parser.add_option( '--master-bind-port', action='store', @@ -156,17 +165,18 @@ def parse_options(): default=1, help="The rate per second in which clients are spawned. Only used together with --no-web" ) - + # Number of requests parser.add_option( '-n', '--num-request', action='store', + type='int', dest='num_requests', default=None, help="Number of requests to perform. Only used together with --no-web" ) - + # log level parser.add_option( '--loglevel', '-L', @@ -176,7 +186,7 @@ def parse_options(): default='INFO', help="Choose between DEBUG/INFO/WARNING/ERROR/CRITICAL. Default is INFO.", ) - + # log file parser.add_option( '--logfile', @@ -186,7 +196,7 @@ def parse_options(): default=None, help="Path to log file. If not set, log will go to stdout/stderr", ) - + # if we should print stats in the console parser.add_option( '--print-stats', @@ -206,13 +216,13 @@ def parse_options(): ) parser.add_option( - '--no-reset-stats', + '--reset-stats', action='store_true', - dest='no_reset_stats', + dest='reset_stats', default=False, - help="Do not reset statistics once hatching has been completed", + help="Reset statistics once hatching has been completed", ) - + # List locust commands found in loaded locust files/source files parser.add_option( '-l', '--list', @@ -221,7 +231,7 @@ def parse_options(): default=False, help="Show list of possible locust classes and exit" ) - + # Display ratio table of all tasks parser.add_option( '--show-task-ratio', @@ -238,7 +248,7 @@ def parse_options(): default=False, help="print json data of the locust classes' task execution ratio" ) - + # Version number (optparse gives you --version but we have to do it # ourselves to get -V too. sigh) parser.add_option( @@ -315,7 +325,7 @@ def main(): else: # list() call is needed to consume the dict_view object in Python 3 locust_classes = list(locusts.values()) - + if options.show_task_ratio: console_logger.info("\n Task ratio per locust class") console_logger.info( "-" * 80) @@ -337,7 +347,7 @@ def main(): # spawn web greenlet logger.info("Starting web monitor at %s:%s" % (options.web_host or "*", options.port)) main_greenlet = gevent.spawn(web.start, locust_classes, options) - + if not options.master and not options.slave: runners.locust_runner = LocalLocustRunner(locust_classes, options, available_locustfiles=all_locustfiles) # spawn client spawning/hatching greenlet @@ -361,30 +371,35 @@ def main(): except socket.error as e: logger.error("Failed to connect to the Locust master: %s", e) sys.exit(-1) - + if not options.only_summary and (options.print_stats or (options.no_web and not options.slave)): # spawn stats printing greenlet gevent.spawn(stats_printer) + if options.csvfilebase: + gevent.spawn(stats_writer, options.csvfilebase) + + def shutdown(code=0): """ - Shut down locust by firing quitting event, printing stats and exiting + Shut down locust by firing quitting event, printing/writing stats and exiting """ logger.info("Shutting down (exit code %s), bye." % code) events.quitting.fire() print_stats(runners.locust_runner.request_stats) print_percentile_stats(runners.locust_runner.request_stats) - + if options.csvfilebase: + write_stat_csvs(options.csvfilebase) print_error_report() sys.exit(code) - + # install SIGTERM handler def sig_term_handler(): logger.info("Got SIGTERM signal") shutdown(0) gevent.signal(signal.SIGTERM, sig_term_handler) - + try: logger.info("Starting Locust %s" % version) main_greenlet.join() diff --git a/locust/rpc/protocol.py b/locust/rpc/protocol.py index 66ce2c72cf..b30a36f8bc 100644 --- a/locust/rpc/protocol.py +++ b/locust/rpc/protocol.py @@ -1,5 +1,6 @@ import msgpack + class Message(object): def __init__(self, message_type, data, node_id): self.type = message_type diff --git a/locust/rpc/socketrpc.py b/locust/rpc/socketrpc.py index 034efe6ff7..89c5b05ae1 100644 --- a/locust/rpc/socketrpc.py +++ b/locust/rpc/socketrpc.py @@ -1,10 +1,11 @@ -import struct import logging +import struct import gevent from gevent import socket from locust.exception import LocustError + from .protocol import Message logger = logging.getLogger(__name__) diff --git a/locust/rpc/zmqrpc.py b/locust/rpc/zmqrpc.py index 44a3bff13b..07583cb0a7 100644 --- a/locust/rpc/zmqrpc.py +++ b/locust/rpc/zmqrpc.py @@ -1,6 +1,8 @@ import zmq.green as zmq + from .protocol import Message + class BaseSocket(object): def send(self, msg): diff --git a/locust/runners.py b/locust/runners.py index 0bb779d639..a43f4c1786 100644 --- a/locust/runners.py +++ b/locust/runners.py @@ -1,23 +1,22 @@ -# coding=UTF-8 +# -*- coding: utf-8 -*- +import logging +import random import socket import traceback import warnings -import random -import logging -from time import time from hashlib import md5 +from time import time import gevent +import six from gevent import GreenletExit from gevent.pool import Group -import six + from six.moves import xrange from . import events, configuration +from .rpc import Message, rpc from .stats import global_stats - -from .rpc import rpc, Message - import fileio import tests_loader @@ -50,19 +49,19 @@ def __init__(self, locust_classes, options, available_locustfiles=None): # register listener that resets stats when hatching is complete def on_hatch_complete(user_count): self.state = STATE_RUNNING - if not self.options.no_reset_stats: - logger.info("Resetting stats is skipped\n") - # self.stats.reset_all() + if self.options.reset_stats: + logger.info("Resetting stats\n") + self.stats.reset_all() events.hatch_complete += on_hatch_complete @property def request_stats(self): return self.stats.entries - + @property def errors(self): return self.stats.errors - + @property def user_count(self): return len(self.locusts) @@ -107,7 +106,7 @@ def spawn_locusts(self, spawn_count=None, stop_timeout=None, wait=False): logger.info("Hatching and swarming %i clients at the rate %g clients/s..." % (spawn_count, self.hatch_rate)) occurence_count = dict([(l.__name__, 0) for l in self.locust_classes]) - + def hatch(): sleep_time = 1.0 / self.hatch_rate while True: @@ -127,7 +126,7 @@ def start_locust(_): if len(self.locusts) % 10 == 0: logger.debug("%i locusts hatched" % len(self.locusts)) gevent.sleep(sleep_time) - + hatch() if wait: self.locusts.join() @@ -231,7 +230,7 @@ def __init__(self, locust_classes, options, available_locustfiles=None): self.master_port = options.master_port self.master_bind_host = options.master_bind_host self.master_bind_port = options.master_bind_port - + def noop(self, *args, **kwargs): """ Used to link() greenlets to in order to be compatible with gevent 1.0 """ pass @@ -245,27 +244,28 @@ def __init__(self, id, state=STATE_INIT): class MasterLocustRunner(DistributedLocustRunner): def __init__(self, *args, **kwargs): super(MasterLocustRunner, self).__init__(*args, **kwargs) - + class SlaveNodesDict(dict): def get_by_state(self, state): return [c for c in six.itervalues(self) if c.state == state] - + @property def ready(self): return self.get_by_state(STATE_INIT) - + @property def hatching(self): return self.get_by_state(STATE_HATCHING) - + @property def running(self): return self.get_by_state(STATE_RUNNING) + self.clients = SlaveNodesDict() self.server = rpc.Server(self.master_bind_host, self.master_bind_port) self.greenlet = Group() self.greenlet.spawn(self.client_listener).link_exception(callback=self.noop) - + # listener that gathers info on how many locust users the slaves has spawned def on_slave_report(client_id, data): if client_id not in self.clients: @@ -274,12 +274,12 @@ def on_slave_report(client_id, data): self.clients[client_id].user_count = data["user_count"] events.slave_report += on_slave_report - + # register listener that sends quit message to slave nodes def on_quitting(): self.quit() events.quitting += on_quitting - + def on_locust_switch_file(locust_classes_key): for client in six.itervalues(self.clients): self.server.send(Message("switch", locust_classes_key, None)) @@ -304,7 +304,7 @@ def on_master_new_file_uploaded(new_file): @property def user_count(self): return sum([c.user_count for c in six.itervalues(self.clients)]) - + def start_hatching(self, locust_count, hatch_rate): num_slaves = len(self.clients.ready) + len(self.clients.running) if not num_slaves: @@ -340,7 +340,7 @@ def start_hatching(self, locust_count, hatch_rate): remaining -= 1 self.server.send(Message("hatch", data, None)) - + self.stats.start_time = time() self.state = STATE_HATCHING @@ -348,12 +348,12 @@ def stop(self): for client in self.clients.hatching + self.clients.running: self.server.send(Message("stop", None, None)) events.master_stop_hatching.fire() - + def quit(self): for client in six.itervalues(self.clients): self.server.send(Message("quit", None, None)) self.greenlet.kill(block=True) - + def client_listener(self): while True: msg = self.server.recv() @@ -394,24 +394,24 @@ class SlaveLocustRunner(DistributedLocustRunner): def __init__(self, *args, **kwargs): super(SlaveLocustRunner, self).__init__(*args, **kwargs) self.client_id = socket.gethostname() + "_" + md5(str(time() + random.randint(0,10000)).encode('utf-8')).hexdigest() - + self.client = rpc.Client(self.master_host, self.master_port) self.greenlet = Group() self.greenlet.spawn(self.worker).link_exception(callback=self.noop) self.client.send(Message("client_ready", None, self.client_id)) self.greenlet.spawn(self.stats_reporter).link_exception(callback=self.noop) - + # register listener for when all locust users have hatched, and report it to the master node def on_hatch_complete(user_count): self.client.send(Message("hatch_complete", {"count":user_count}, self.client_id)) events.hatch_complete += on_hatch_complete - - # register listener that adds the current number of spawned locusts to the report that is sent to the master node + + # register listener that adds the current number of spawned locusts to the report that is sent to the master node def on_report_to_master(client_id, data): data["user_count"] = self.user_count events.report_to_master += on_report_to_master - + # register listener that sends quit message to master def on_quitting(): self.client.send(Message("quit", None, self.client_id)) @@ -468,5 +468,5 @@ def stats_reporter(self): except: logger.error("Connection lost to master server. Aborting...") break - + gevent.sleep(SLAVE_REPORT_INTERVAL) diff --git a/locust/static/locust.js b/locust/static/locust.js index 28e3b976e0..c9ec284c67 100644 --- a/locust/static/locust.js +++ b/locust/static/locust.js @@ -170,19 +170,27 @@ $(".edit_config_link").click(function(event) { }); -$('#upload_btn_submit').click(function(event){ +$("#directories .select2").select2({ + placeholder: "Select a state" +}); + +let whichform = $('.upload_file_form_test_file')[0]; + +$('#upload_py_submit').click(function(event){ event.preventDefault(); - $('#upload_file_form').submit(); + whichform = $('.upload_file_form_test_file')[0]; + $('.upload_file_form_test_file').submit(); }); -$("#directories .select2").select2({ - placeholder: "Select a state" +$('#upload_json_submit').click(function(event){ + event.preventDefault(); + whichform = $('.upload_file_form_json')[0]; + $('.upload_file_form_json').submit(); }); -$('#upload_file_form').submit(function(event) { +$('.upload_file_form_test_file, .upload_file_form_json').submit(function(event) { event.preventDefault(); - var form = $('#upload_file_form')[0]; - var form_data = new FormData(form); + var form_data = new FormData(whichform); $.ajax({ type: 'POST', url: "/upload_file", @@ -201,7 +209,6 @@ $('#upload_file_form').submit(function(event) { }) }); - $('#submit_json_btn').click(function(){ event.preventDefault(); $('#hidden_config_json').val(JSON.stringify(json_editor.get(), null , 4)); diff --git a/locust/static/style.css b/locust/static/style.css index 20480eed75..92994f4096 100644 --- a/locust/static/style.css +++ b/locust/static/style.css @@ -308,7 +308,7 @@ label[for="json_option"]+div{ .start button, .edit button, .edit_config button[type=submit], .multiple_column button, -#new-test-confirmation .btn-newtest, #upload_btn_submit { +#new-test-confirmation .btn-newtest, #upload_json_submit, #upload_py_submit { margin: 20px 0px 20px 0px; float: right; font-size: 14px; @@ -566,7 +566,7 @@ ul.tabs li a.current:after { } -#add-new-file-modal, #new-test-confirmation { +#add-new-file-modal, #upload-json-modal, #new-test-confirmation { width: 398px; position: absolute; left: 50%; @@ -580,33 +580,36 @@ ul.tabs li a.current:after { background: #132b21; box-shadow: 0 0 120px rgba(0,0,0,0.3); } -#add-new-file-modal{ + +#add-new-file-modal, #upload-json-modal { padding-left : 1%; padding-right: 1%; height: 275px; } #new-test-confirmation { height: 160px; + width: 450px; } -#add-new-file-modal .modal-dialog, #new-test-confirmation .modal-dialog{ + +#add-new-file-modal .modal-dialog, #new-test-confirmation .modal-dialog, #upload-json-modal .modal-dialog{ display: flex; flex-direction: column; vertical-align: middle; margin-top: 0px; margin-bottom: -50px; } -#add-new-file-modal .modal-header, #new-test-confirmation .modal-header { +#add-new-file-modal .modal-header, #new-test-confirmation .modal-header, #upload-json-modal .modal-header { border-bottom-color: transparent; } -#add-new-file-modal .close, #new-test-confirmation .close { +#add-new-file-modal .close, #new-test-confirmation .close, #upload-json-modal .close { color: #addf82; } -#new-test-confirmation .modal-header .close, #add-new-file-modal .close{ +#new-test-confirmation .modal-header .close, #add-new-file-modal .close, #upload-json-modal .modal-header .close{ margin-right: -25px; padding: 3px; } -#new-test-confirmation .modal-body, #add-new-file-modal .modal-body { +#new-test-confirmation .modal-body, #add-new-file-modal .modal-body, #upload-json-modal .modal-body { padding-top: 25px; padding-bottom: 0px; color: white; @@ -616,17 +619,20 @@ ul.tabs li a.current:after { padding: 0; } #new-test-confirmation .btn-newtest { - padding: 8px 30px; - margin: 3px 3px; + padding: 10px 30px; + margin: 4px 4px; +} +#new-test-confirmation .modal-header .close { + margin-right: -10px; } -#add-new-file-modal .modal-content, #new-test-confirmation .modal-content { +#add-new-file-modal .modal-content, #new-test-confirmation .modal-content, #upload-json-modal .modal-content { background-color: #132b21; border-color: transparent; } -#add-new-file-modal .modal-title, #new-test-confirmation .modal-title { +#add-new-file-modal .modal-title, #new-test-confirmation .modal-title, #upload-json-modal .modal-title { color:#addf82; } -#upload_file_form, #new-test-confirmation { +.upload_file_form_test_file, .upload_file_form_json, #new-test-confirmation { color: white; } .about { diff --git a/locust/stats.py b/locust/stats.py index 7091bac5b2..ea1f76ffb7 100644 --- a/locust/stats.py +++ b/locust/stats.py @@ -1,7 +1,10 @@ +import hashlib import time +from itertools import chain + import gevent -import hashlib import six + from six.moves import xrange from . import events @@ -10,6 +13,13 @@ STATS_NAME_WIDTH = 60 +"""Default interval for how frequently the CSV file is written if this option +is configured.""" +CSV_STATS_INTERVAL_SEC = 2 + +"""Default interval for how frequently results are written to console.""" +CONSOLE_STATS_INTERVAL_SEC = 2 + class RequestStatsAdditionError(Exception): pass @@ -532,7 +542,86 @@ def print_error_report(): console_logger.info("") def stats_printer(): - from .runners import locust_runner + from . import runners + while True: + print_stats(runners.locust_runner.request_stats) + gevent.sleep(CONSOLE_STATS_INTERVAL_SEC) + +def stats_writer(base_filepath): + """Writes the csv files for the locust run.""" while True: - print_stats(locust_runner.request_stats) - gevent.sleep(2) + write_stat_csvs(base_filepath) + gevent.sleep(CSV_STATS_INTERVAL_SEC) + + +def write_stat_csvs(base_filepath): + """Writes the requests and distribution csvs.""" + with open(base_filepath + '_requests.csv', "w") as f: + f.write(requests_csv()) + + with open(base_filepath + '_distribution.csv', 'w') as f: + f.write(distribution_csv()) + + +def sort_stats(stats): + return [stats[key] for key in sorted(six.iterkeys(stats))] + + +def requests_csv(): + from . import runners + + """Returns the contents of the 'requests' tab as CSV.""" + rows = [ + ",".join([ + '"Method"', + '"Name"', + '"# requests"', + '"# failures"', + '"Median response time"', + '"Average response time"', + '"Min response time"', + '"Max response time"', + '"Average Content Size"', + '"Requests/s"', + ]) + ] + + for s in chain(sort_stats(runners.locust_runner.request_stats), [runners.locust_runner.stats.aggregated_stats("Total", full_request_history=True)]): + rows.append('"%s","%s",%i,%i,%i,%i,%i,%i,%i,%.2f' % ( + s.method, + s.name, + s.num_requests, + s.num_failures, + s.median_response_time, + s.avg_response_time, + s.min_response_time or 0, + s.max_response_time, + s.avg_content_length, + s.total_rps, + )) + return "\n".join(rows) + +def distribution_csv(): + """Returns the contents of the 'distribution' tab as CSV.""" + from . import runners + + rows = [",".join(( + '"Name"', + '"# requests"', + '"50%"', + '"66%"', + '"75%"', + '"80%"', + '"90%"', + '"95%"', + '"98%"', + '"99%"', + '"100%"', + ))] + for s in chain(sort_stats(runners.locust_runner.request_stats), [runners.locust_runner.stats.aggregated_stats("Total", full_request_history=True)]): + if s.num_requests: + rows.append(s.percentile(tpl='"%s",%i,%i,%i,%i,%i,%i,%i,%i,%i,%i')) + else: + rows.append('"%s",0,"N/A","N/A","N/A","N/A","N/A","N/A","N/A","N/A","N/A"' % s.name) + + return "\n".join(rows) diff --git a/locust/templates/index.html b/locust/templates/index.html index 2a7fbf74d1..96194d03d0 100644 --- a/locust/templates/index.html +++ b/locust/templates/index.html @@ -132,7 +132,7 @@ @@ -230,7 +230,7 @@

Ramping

+
+
Team Configuration
+
+ +
+ + +