Skip to content

Add Feature: Download Report File #1516

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 8 commits into from
Aug 18, 2020
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion examples/use_as_lib.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import gevent
from locust import HttpUser, task, between
from locust.env import Environment
from locust.stats import stats_printer
from locust.stats import stats_printer, stats_history
from locust.log import setup_logging

setup_logging("INFO", None)
@@ -29,6 +29,9 @@ def task_404(self):
# start a greenlet that periodically outputs the current stats
gevent.spawn(stats_printer(env.stats))

# start a greenlet that save current stats to history
gevent.spawn(stats_history(env.runner))

# start the test
env.runner.start(1, spawn_rate=10)

5 changes: 4 additions & 1 deletion locust/main.py
Original file line number Diff line number Diff line change
@@ -16,7 +16,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
from .stats import print_error_report, print_percentile_stats, print_stats, stats_printer, stats_history
from .stats import StatsCSV, StatsCSVFileWriter
from .user import User
from .user.inspectuser import get_task_ratio_dict, print_task_ratio
@@ -336,6 +336,8 @@ def timelimit_stop():
if options.csv_prefix:
gevent.spawn(stats_csv_writer.stats_writer).link_exception(greenlet_exception_handler)

gevent.spawn(stats_history, runner)

def shutdown():
"""
Shut down locust by firing quitting event, printing/writing stats and exiting
@@ -364,6 +366,7 @@ def shutdown():
print_percentile_stats(runner.stats)

print_error_report(runner.stats)

sys.exit(code)

# install SIGTERM handler
24 changes: 23 additions & 1 deletion locust/stats.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import datetime
import hashlib
import time
from collections import namedtuple, OrderedDict
@@ -18,10 +19,14 @@
"""Default interval for how frequently results are written to console."""
CONSOLE_STATS_INTERVAL_SEC = 2

"""Default interval for how frequently results are written to history."""
HISTORY_STATS_INTERVAL_SEC = 5

"""Default interval for how frequently CSV files are written if this option is configured."""
CSV_STATS_INTERVAL_SEC = 1
CSV_STATS_FLUSH_INTERVAL_SEC = 10


"""
Default window size/resolution - in seconds - when calculating the current
response time percentile
@@ -112,6 +117,7 @@ def __init__(self, use_response_times_cache=True):
self.entries = {}
self.errors = {}
self.total = StatsEntry(self, "Aggregated", None, use_response_times_cache=self.use_response_times_cache)
self.history = []

@property
def num_requests(self):
@@ -167,6 +173,7 @@ def reset_all(self):
self.errors = {}
for r in self.entries.values():
r.reset()
self.history = []

def clear_all(self):
"""
@@ -175,6 +182,7 @@ def clear_all(self):
self.total = StatsEntry(self, "Aggregated", None, use_response_times_cache=self.use_response_times_cache)
self.entries = {}
self.errors = {}
self.history = []

def serialize_stats(self):
return [self.entries[key].get_stripped_report() for key in self.entries.keys() if not (self.entries[key].num_requests == 0 and self.entries[key].num_failures == 0)]
@@ -736,17 +744,31 @@ def print_error_report(stats):
console_logger.info("-" * (80 + STATS_NAME_WIDTH))
console_logger.info("")


def stats_printer(stats):
def stats_printer_func():
while True:
print_stats(stats)
gevent.sleep(CONSOLE_STATS_INTERVAL_SEC)
return stats_printer_func


def sort_stats(stats):
return [stats[key] for key in sorted(stats.keys())]

def stats_history(runner):
"""Save current stats info to history for charts of report."""
while True:
stats = runner.stats
r = {
'time': datetime.datetime.now().strftime("%H:%M:%S"),
'current_rps': stats.total.current_rps or 0,
'current_fail_per_sec': stats.total.current_fail_per_sec or 0,
'response_time_percentile_95': stats.total.get_current_response_time_percentile(0.95) or 0,
'response_time_percentile_50': stats.total.get_current_response_time_percentile(0.5) or 0,
'user_count': runner.user_count or 0,
}
stats.history.append(r)
gevent.sleep(HISTORY_STATS_INTERVAL_SEC)

class StatsCSV():
"""Write statistics to csv_writer stream."""
3 changes: 2 additions & 1 deletion locust/templates/index.html
Original file line number Diff line number Diff line change
@@ -193,7 +193,8 @@ <h2>Edit running load test</h2>
<a href="./stats/requests_full_history/csv">Download full request statistics history CSV</a><br>
{% endif %}
<a href="./stats/failures/csv">Download failures CSV</a><br>
<a href="./exceptions/csv">Download exceptions CSV</a>
<a href="./exceptions/csv">Download exceptions CSV</a><br>
<a href="./stats/report" target="_blank">Download Report</a><br>
</div>
</div>
<div style="display:none;">
257 changes: 257 additions & 0 deletions locust/templates/report.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,257 @@
<!DOCTYPE html>
<html>
<head>
<title>Test Report</title>
<style>
.container {
width: 1000px;
margin: 0 auto;
padding: 10px;
background: #173529;
font-family: Arial, Helvetica, sans-serif;
font-size: 14px;
color: #fff;
}

.info span{
color: #b3c3bc;
}

table {
border-collapse: collapse;
text-align: center;
width: 100%;
}

td, th {
border: 1px solid #cad9ea;
color: #666;
height: 30px;
}

thead th {
background-color: #cce8eb;
width: 100px;
}

tr:nth-child(odd) {
background: #fff;
}

tr:nth-child(even) {
background: #f5fafa;
}

.charts-container .chart {
width: 100%;
height: 350px;
margin-bottom: 30px;
}

.download {
float: right;
}

.download a {
color: #00ca5a;
}
</style>
</head>
<body>
<div class="container">
<h1>Locust Test Report</h1>

<div class="info">
<p class="download"><a href="?download=1">Download the Report</a></p>
<p>During: <span>{{ start_time }} - {{ end_time }}</span></p>
<p>Target Host: <span>{{ host }}</span></p>
</div>

<div class="requests">
<h2>Request Statistics</h2>
<table>
<thead>
<tr>
<th>Method</th>
<th>Name</th>
<th># Requests</th>
<th># Fails</th>
<th>Average (ms)</th>
<th>Min (ms)</th>
<th>Max (ms)</th>
<th>Average size (bytes)</th>
<th>RPS</th>
<th>Failures/s</th>
</tr>
</thead>
<tbody>
{% for s in requests_statistics %}
<tr>
<td>{{ s.method or "" }}</td>
<td>{{ s.name }}</td>
<td>{{ int(s.num_requests) }}</td>
<td>{{ int(s.num_failures) }}</td>
<td>{{ int(s.avg_response_time) }}</td>
<td>{{ int(s.min_response_time or 0) }}</td>
<td>{{ int(s.max_response_time) }}</td>
<td>{{ int(s.avg_content_length) }}</td>
<td>{{ round(s.total_rps, 1) }}</td>
<td>{{ round(s.total_fail_per_sec, 1) }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>

<div class="responses">
<h2>Response Time Statistics</h2>
<table>
<thead>
<tr>
<th>Method</th>
<th>Name</th>
<th>50%ile (ms)</th>
<th>60%ile (ms)</th>
<th>70%ile (ms)</th>
<th>80%ile (ms)</th>
<th>90%ile (ms)</th>
<th>95%ile (ms)</th>
<th>99%ile (ms)</th>
<th>100%ile (ms)</th>
</tr>
</thead>
<tbody>
{% for s in requests_statistics %}
<tr>
<td>{{ s.method or "" }}</td>
<td>{{ s.name }}</td>
<td>{{ int(s.get_response_time_percentile(0.5)) }}</td>
<td>{{ int(s.get_response_time_percentile(0.6)) }}</td>
<td>{{ int(s.get_response_time_percentile(0.7)) }}</td>
<td>{{ int(s.get_response_time_percentile(0.8)) }}</td>
<td>{{ int(s.get_response_time_percentile(0.9)) }}</td>
<td>{{ int(s.get_response_time_percentile(0.95)) }}</td>
<td>{{ int(s.get_response_time_percentile(0.99)) }}</td>
<td>{{ int(s.get_response_time_percentile(1)) }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>

{% if failures_statistics %}
<div class="failures">
<h2>Failures Statistics</h2>
<table>
<thead>
<tr>
<th>Method</th>
<th>Name</th>
<th>Error</th>
<th>Occurrences</th>
</tr>
</thead>
<tbody>
{% for s in failures_statistics %}
<tr>
<td>{{ s.method or "" }}</td>
<td>{{ s.name }}</td>
<td>{{ s.error }}</td>
<td>{{ s.occurrences }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}

{% if exceptions_statistics %}
<div class="exceptions">
<h2>Exceptions Statistics</h2>
<table>
<thead>
<tr>
<th>Count</th>
<th>Message</th>
<th>Traceback</th>
<th>Nodes</th>
</tr>
</thead>
<tbody>
{% for s in exceptions_statistics %}
<tr>
<td>{{ s.count }}</td>
<td>{{ s.msg }}</td>
<td>{{ s.traceback }}</td>
<td>{{ s.nodes }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}

{% if history %}
<div class="charts-container">
<h2>Charts</h2>
</div>
{% endif %}
</div>

{# <script type="text/javascript" src="/static/jquery-1.11.3.min.js"></script> #}
{# <script type="text/javascript" src="/static/echarts.common.min.js"></script> #}
{# <script type="text/javascript" src="/static/vintage.js"></script> #}
{# <script type="text/javascript" src="/static/chart.js"></script> #}
<script>
{{ static_js|safe }}
</script>

<script>

var rpsChart = new LocustLineChart($(".charts-container"), "Total Requests per Second", ["RPS", "Failures/s"], "reqs/s", ['#00ca5a', '#ff6d6d']);
var responseTimeChart = new LocustLineChart($(".charts-container"), "Response Times (ms)", ["Median Response Time", "95% percentile"], "ms");
var usersChart = new LocustLineChart($(".charts-container"), "Number of Users", ["Users"], "users");

rpsChart.chart.setOption({
xAxis: {
data: [ {% for r in history %}"{{ r.time }}", {% endfor %} ],
},
series: [
{
data: [ {% for r in history %}{{ r.current_rps }}, {% endfor %} ]
},
{
data: [ {% for r in history %}{{ r.current_fail_per_sec }}, {% endfor %} ]
},
]
});

responseTimeChart.chart.setOption({
xAxis: {
data: [ {% for r in history %}"{{ r.time }}", {% endfor %} ],
},
series: [
{
data: [ {% for r in history %}{{ r.response_time_percentile_50 }}, {% endfor %} ]
},
{
data: [ {% for r in history %}{{ r.response_time_percentile_95 }}, {% endfor %} ]
},
]
});

usersChart.chart.setOption({
xAxis: {
data: [ {% for r in history %}"{{ r.time }}", {% endfor %} ],
},
series: [
{
data: [ {% for r in history %}{{ r.user_count }}, {% endfor %} ]
},
]
});


</script>
</body>
</html>
45 changes: 45 additions & 0 deletions locust/test/test_web.py
Original file line number Diff line number Diff line change
@@ -304,6 +304,51 @@ def my_task(self):
self.assertEqual(200, response.status_code)
self.assertIn("Step Load Mode", response.text)

def test_report_page(self):
self.stats.log_request("GET", "/test", 120, 5612)
r = requests.get("http://127.0.0.1:%i/stats/report" % self.web_port)
self.assertEqual(200, r.status_code)
self.assertIn("<title>Test Report</title>", r.text)
self.assertIn("charts-container", r.text)

def test_report_download(self):
self.stats.log_request("GET", "/test", 120, 5612)
r = requests.get("http://127.0.0.1:%i/stats/report?download=1" % self.web_port)
self.assertEqual(200, r.status_code)
self.assertIn('attachment', r.headers.get("Content-Disposition", ""))

def test_report_host(self):
self.environment.host = "http://test.com"
self.stats.log_request("GET", "/test", 120, 5612)
r = requests.get("http://127.0.0.1:%i/stats/report" % self.web_port)
self.assertEqual(200, r.status_code)
self.assertIn("http://test.com", r.text)

def test_report_host2(self):
class MyUser(User):
host = "http://test2.com"
@task
def my_task(self):
pass
self.environment.host = None
self.environment.user_classes = [MyUser]
self.stats.log_request("GET", "/test", 120, 5612)
r = requests.get("http://127.0.0.1:%i/stats/report" % self.web_port)
self.assertEqual(200, r.status_code)
self.assertIn("http://test2.com", r.text)

def test_report_exceptions(self):
try:
raise Exception("Test exception")
except Exception as e:
tb = sys.exc_info()[2]
self.runner.log_exception("local", str(e), "".join(traceback.format_tb(tb)))
self.runner.log_exception("local", str(e), "".join(traceback.format_tb(tb)))
self.stats.log_request("GET", "/test", 120, 5612)
r = requests.get("http://127.0.0.1:%i/stats/report" % self.web_port)
# self.assertEqual(200, r.status_code)
self.assertIn("<h2>Exceptions Statistics</h2>", r.text)


class TestWebUIAuth(LocustTestCase):
def setUp(self):
65 changes: 62 additions & 3 deletions locust/web.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# -*- coding: utf-8 -*-

import csv
import datetime
import logging
import os.path
from functools import wraps
@@ -34,7 +35,9 @@

class WebUI:
"""
Sets up and runs a Flask web app that can start and stop load tests using the
Sets up and runs a Flask web
that can start and stop load tests using the
:attr:`environment.runner <locust.env.Environment.runner>` as well as show the load test statistics
in :attr:`environment.stats <locust.env.Environment.stats>`
"""
@@ -176,6 +179,62 @@ def reset_stats():
environment.runner.stats.reset_all()
environment.runner.exceptions = {}
return "ok"

@app.route("/stats/report")
@self.auth_required_if_enabled
def stats_report():
stats = self.environment.runner.stats

start_ts = stats.start_time
start_time = datetime.datetime.fromtimestamp(start_ts)
start_time = start_time.strftime('%Y-%m-%d %H:%M:%S')

end_ts = stats.last_request_timestamp
end_time = datetime.datetime.fromtimestamp(end_ts)
end_time = end_time.strftime('%Y-%m-%d %H:%M:%S')

host = None
if environment.host:
host = environment.host
elif environment.runner.user_classes:
all_hosts = set([l.host for l in environment.runner.user_classes])
if len(all_hosts) == 1:
host = list(all_hosts)[0]

requests_statistics = list(chain(sort_stats(stats.entries), [stats.total]))
failures_statistics = sort_stats(stats.errors)
exceptions_statistics = []
for exc in environment.runner.exceptions.values():
exc['nodes'] = ", ".join(exc["nodes"])
exceptions_statistics.append(exc)

history = stats.history

static_js = ''
js_files = ['jquery-1.11.3.min.js', 'echarts.common.min.js', 'vintage.js', 'chart.js']
for js_file in js_files:
path = os.path.join(os.path.dirname(__file__), 'static', js_file)
content = open(path, encoding='utf8').read()
static_js += '// ' + js_file + '\n'
static_js += content
static_js += '\n\n\n'

res = render_template('report.html',
int=int,
round=round,
requests_statistics=requests_statistics,
failures_statistics=failures_statistics,
exceptions_statistics=exceptions_statistics,
start_time=start_time,
end_time=end_time,
host=host,
history=history,
static_js=static_js,
)
if request.args.get('download'):
res = app.make_response(res)
res.headers["Content-Disposition"] = "attachment;filename=report_%s.html" % time()
return res

def _download_csv_suggest_file_name(suggest_filename_prefix):
"""Generate csv file download attachment filename suggestion.
@@ -280,7 +339,7 @@ def request_stats():

report["state"] = environment.runner.state
report["user_count"] = environment.runner.user_count

return jsonify(report)

@app.route("/exceptions")
@@ -292,7 +351,7 @@ def exceptions():
"count": row["count"],
"msg": row["msg"],
"traceback": row["traceback"],
"nodes" : ", ".join(row["nodes"])
"nodes": ", ".join(row["nodes"])
} for row in environment.runner.exceptions.values()
]
})