Skip to content

Commit b6a2c84

Browse files
committed
Enable timing stats reporting in REST interface.
1 parent 184eb12 commit b6a2c84

File tree

4 files changed

+148
-2
lines changed

4 files changed

+148
-2
lines changed

CHANGES.txt

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,9 @@ Features:
4343
(John Rouillard)
4444
- New filter command defined in roundup-admin. (Partial fix for
4545
issue724648.) (John Rouillard)
46-
46+
- New parameter @stats for REST interface that provides the same
47+
performance stats as the web interface's CGI_SHOW_TIMING env
48+
variable. (John Rouillard)
4749

4850
2020-04-05 2.0.0 beta 0
4951

doc/rest.txt

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,33 @@ will be returned. It should be used as a hint that the REST endpoint
137137
will be going away. See https://tools.ietf.org/html/rfc8594 for
138138
details on this header and the sunset link type.
139139

140+
Hyperdb Stats
141+
=============
142+
143+
Adding ``@stats=true`` as a GET query parameter or POST data item will
144+
augment the response with an ``@stats`` dictionary. Any value other
145+
than ``true`` (any case) will disable the ``@stats`` dictionary. When
146+
stats are enabled the response includes an ``@stats`` member and looks
147+
like::
148+
149+
{ "data": {
150+
...
151+
"@stats": {
152+
"cache_hits": 3,
153+
"cache_misses": 1,
154+
"get_items": 0.0009722709655761719,
155+
"filtering": 0,
156+
"elapsed": 0.04731464385986328
157+
}
158+
}
159+
}
160+
161+
These are the same values returned in the html interface by setting
162+
the ``CGI_SHOW_TIMING`` environment variable. By default performance
163+
stats are not shown. The fields are subject to change. An
164+
understanding of the code is recommended if you are going to use this
165+
info.
166+
140167
Versioning
141168
==========
142169

roundup/rest.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,9 @@ def format_object(self, *args, **kwargs):
116116
}
117117
}
118118
else:
119+
if hasattr(self.db, 'stats') and self.report_stats:
120+
self.db.stats['elapsed'] = time.time()-self.start
121+
data['@stats'] = self.db.stats
119122
result = {
120123
'data': data
121124
}
@@ -370,6 +373,11 @@ def __init__(self, client, db):
370373
self.client = client
371374
self.db = db
372375
self.translator = client.translator
376+
# record start time for statistics reporting
377+
self.start = time.time()
378+
# disable stat reporting by default enable with @stats=True
379+
# query param
380+
self.report_stats = False
373381
# This used to be initialized from client.instance.actions which
374382
# would include too many actions that do not make sense in the
375383
# REST-API context, so for now we only permit the retire and
@@ -1951,6 +1959,14 @@ def dispatch(self, method, uri, input):
19511959
except (KeyError, TypeError):
19521960
pretty_output = True
19531961

1962+
# check for runtime statistics
1963+
try:
1964+
self.report_stats = input['@stats'].value.lower() == "true"
1965+
# Can also return a TypeError ("not indexable")
1966+
# In case the FieldStorage could not parse the result
1967+
except (KeyError, TypeError):
1968+
report_stats = False
1969+
19541970
# check for @apiver in query string
19551971
msg = ("Unrecognized version: %s. "
19561972
"See /rest without specifying version "

test/rest_common.py

Lines changed: 102 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ def dst(self, dt):
3535
from roundup.rest import RestfulInstance, calculate_etag
3636
from roundup.backends import list_backends
3737
from roundup.cgi import client
38-
from roundup.anypy.strings import b2s, s2b
38+
from roundup.anypy.strings import b2s, s2b, us2u
3939
import random
4040

4141
from roundup.backends.sessions_dbm import OneTimeKeys
@@ -1319,6 +1319,107 @@ def testDispatchPost(self):
13191319
"http://tracker.example/cgi-bin/roundup.cgi/bugs/rest/data/user/2")
13201320

13211321

1322+
def testStatsGen(self):
1323+
# check stats being returned by put and get ops
1324+
# using dispatch which parses the @stats query param
1325+
1326+
# find correct py2/py3 list comparison ignoring order
1327+
try:
1328+
list_test = self.assertCountEqual # py3
1329+
except AttributeError:
1330+
list_test = self.assertItemsEqual # py2.7+
1331+
1332+
# get stats
1333+
form = cgi.FieldStorage()
1334+
form.list = [
1335+
cgi.MiniFieldStorage('@stats', 'True'),
1336+
]
1337+
results = self.server.dispatch('GET',
1338+
"/rest/data/user/1/realname",
1339+
form)
1340+
self.assertEqual(self.dummy_client.response_code, 200)
1341+
json_dict = json.loads(b2s(results))
1342+
1343+
# check that @stats are defined
1344+
self.assertTrue( '@stats' in json_dict['data'] )
1345+
# check that the keys are present
1346+
# not validating values as that changes
1347+
valid_fields= [ us2u('elapsed'),
1348+
us2u('cache_hits'),
1349+
us2u('cache_misses'),
1350+
us2u('get_items'),
1351+
us2u('filtering') ]
1352+
list_test(valid_fields,json_dict['data']['@stats'].keys())
1353+
1354+
# Make sure false value works to suppress @stats
1355+
form = cgi.FieldStorage()
1356+
form.list = [
1357+
cgi.MiniFieldStorage('@stats', 'False'),
1358+
]
1359+
results = self.server.dispatch('GET',
1360+
"/rest/data/user/1/realname",
1361+
form)
1362+
self.assertEqual(self.dummy_client.response_code, 200)
1363+
json_dict = json.loads(b2s(results))
1364+
print(results)
1365+
# check that @stats are not defined
1366+
self.assertTrue( '@stats' not in json_dict['data'] )
1367+
1368+
# Make sure non-true value works to suppress @stats
1369+
# false will always work
1370+
form = cgi.FieldStorage()
1371+
form.list = [
1372+
cgi.MiniFieldStorage('@stats', 'random'),
1373+
]
1374+
results = self.server.dispatch('GET',
1375+
"/rest/data/user/1/realname",
1376+
form)
1377+
self.assertEqual(self.dummy_client.response_code, 200)
1378+
json_dict = json.loads(b2s(results))
1379+
print(results)
1380+
# check that @stats are not defined
1381+
self.assertTrue( '@stats' not in json_dict['data'] )
1382+
1383+
# if @stats is not defined there should be no stats
1384+
results = self.server.dispatch('GET',
1385+
"/rest/data/user/1/realname",
1386+
self.empty_form)
1387+
self.assertEqual(self.dummy_client.response_code, 200)
1388+
json_dict = json.loads(b2s(results))
1389+
1390+
# check that @stats are not defined
1391+
self.assertTrue( '@stats' not in json_dict['data'] )
1392+
1393+
1394+
1395+
# change admin's realname via a normal web form
1396+
# This generates a FieldStorage that looks like:
1397+
# FieldStorage(None, None, [])
1398+
# use etag from header
1399+
#
1400+
# Also use GET on the uri via the dispatch to retrieve
1401+
# the results from the db.
1402+
etag = calculate_etag(self.db.user.getnode('1'),
1403+
self.db.config['WEB_SECRET_KEY'])
1404+
headers={"if-match": etag,
1405+
"accept": "application/vnd.json.test-v1+json",
1406+
}
1407+
form = cgi.FieldStorage()
1408+
form.list = [
1409+
cgi.MiniFieldStorage('data', 'Joe Doe'),
1410+
cgi.MiniFieldStorage('@apiver', '1'),
1411+
cgi.MiniFieldStorage('@stats', 'true'),
1412+
]
1413+
self.headers = headers
1414+
self.server.client.request.headers.get = self.get_header
1415+
self.db.setCurrentUser('admin') # must be admin to change user
1416+
results = self.server.dispatch('PUT',
1417+
"/rest/data/user/1/realname",
1418+
form)
1419+
self.assertEqual(self.dummy_client.response_code, 200)
1420+
json_dict = json.loads(b2s(results))
1421+
list_test(valid_fields,json_dict['data']['@stats'].keys())
1422+
13221423
def testDispatch(self):
13231424
"""
13241425
run changes through rest dispatch(). This also tests

0 commit comments

Comments
 (0)