Adds profiling tools and exposes profiler web UI#81
Conversation
…including the function name
| plugins/* | ||
| !plugins/conftest.py | ||
| data/sessions/* | ||
| data/profiler/* |
There was a problem hiding this comment.
Profiler data files are stored in data/profiler/ by default.
|
|
||
|
|
||
| [cherrypy] | ||
| checker.check_skipped_app_config = boolean(default=False) |
There was a problem hiding this comment.
I moved this line just to put it in alphabetical order. In retrospect that was probably silly, but oh well!
| 'ConfigurationError', 'parse_config', | ||
| 'is_listy', 'listify', 'serializer', 'cached_property', 'class_property', 'entry_point', | ||
| 'stopped', 'on_startup', 'on_shutdown', 'mainloop', 'ajax', 'renders_template', 'render_with_templates', | ||
| 'restricted', 'all_restricted', 'register_authenticator' |
There was a problem hiding this comment.
Funny this line was missing a comma, so I think the two items were concatenated and register_authenticatorDaemonTask was part of __all__.
On a stylistic note, in my other projects I've started defining __all__ in my submodules and then doing import * to make the submodule exports available in the parent module. For example inside sideboard.lib._profiler I'll have:
__all__ = ['cleanup_profiler', 'profile', 'Profiler', 'ProfileAggregator']and then in the parent module I'll have:
from sideboard.lib._profiler import *That way each individual module is responsible for what it is exporting, and the parent module can easily import everything for easy access elsewhere. You get the same functionality we have here, but without an unwieldy __all__ list.
There was a problem hiding this comment.
Funny this line was missing a comma, so I think the two items were concatenated and register_authenticatorDaemonTask was part of all.
Whoops! I had exactly the same thing happen in a different codebase at work recently. It's one of those typos you don't notice until it actually causes an error. Good catch!
...You get the same functionality we have here, but without an unwieldy all list.
Hmm, I'll have to think of that. I kind of like the way we have it now, because sideboard.lib is the place where you import non-SQLAlchemy Sideboard utilities, which means that there's a single file where you can look to find a complete list of everything that Sideboard exposes. That's a desirable property for a codebase to have IMO.
|
|
||
| @cherrypy.expose | ||
| def menu(self): | ||
| yield '<h2>Profiling Runs</h2>' |
There was a problem hiding this comment.
I am not crazy about manually concatenating a bunch of HTML strings together, but this is how the cherrypy module was doing it, and I didn't feel like rewriting a bunch of working code.
| if config['cherrypy']['profiling.on']: | ||
| # If profiling is turned on then expose the web UI, otherwise ignore it. | ||
| from sideboard.lib import Profiler | ||
| cherrypy.tree.mount(Profiler(config['cherrypy']['profiling.path']), '/profiler') |
There was a problem hiding this comment.
This is where we conditionally enable the profiler web UI.
| pass | ||
|
|
||
|
|
||
| def test_profile_is_noop(monkeypatch): |
There was a problem hiding this comment.
The only thing I am testing is that the @profile decorator is a no-op when profiling is disabled. This is the only code that will be running in production. Everything else in the profiler module is per se testing code.
| deps= -rrequirements.txt | ||
| commands= | ||
| coverage run --source sideboard -m py.test {posargs} | ||
| coverage run --source sideboard -m py.test {posargs} sideboard |
There was a problem hiding this comment.
This was preventing tests from running in our Vagrant deployments, because it would try to test everything in the plugins directory as well.
| is_test_running = True | ||
|
|
||
| # Prevent plugins from loading during tests | ||
| plugins_dir = '%(root)s/test_plugins' |
There was a problem hiding this comment.
This is a non-existent directory to prevent sideboard from attempting to load plugins during tests.
There was a problem hiding this comment.
Minor suggestion to make that more self-documenting: use does_not_exist instead of test_plugins.
There was a problem hiding this comment.
I actually originally had that exact string! And then I was like, "well, maybe we DO want some test_plugins!" But I think you're right (and we have already have tests for loading test plugins).
kitsuta
left a comment
There was a problem hiding this comment.
This looks pretty good! This may seem like a silly question, but if a lot of this is just extending functionality in CherryPy, is there no way we can link to CherryPy's functions to extend them rather than copy-pasting their contents?
|
|
||
| from sideboard.lib import profile | ||
|
|
||
| def some_long_running_function(): |
There was a problem hiding this comment.
This example function doesn't have the decorator described above. An oversight, maybe?
| @entry_point | ||
| def cleanup_profiler(): | ||
| """ | ||
| Deletes all `*.prof` files in the profiler's data directory. |
There was a problem hiding this comment.
What cases would this be useful? And if possible, can you describe an example of why you might want to do this in the stringdoc?
There was a problem hiding this comment.
After a load test run you can have hundreds or thousands of profiling files. This is just a convenient way of deleting them.
| return '''<html> | ||
| <head><title>Sideboard Profiler</title></head> | ||
| <frameset cols="300, 1*"> | ||
| <frame src="menu"/> |
There was a problem hiding this comment.
Blame the cherrypy folks! 😜
|
If you look through the commit history, you'll see I was originally just extending their classes. After switching from |
|
Fair enough! |
EliAndrewC
left a comment
There was a problem hiding this comment.
Great stuff! I had a couple of minor suggestions, mostly style suggestions, most of which you can feel free to ignore if you prefer the style the way you have it.
| # If the profiler is disabled, then the @sideboard.lib.profile decorator | ||
| # becomes a no-op and no performance penalty is incurred. The web interface | ||
| # will not be created and visits to http://servername/profiler/ will 404. | ||
| # True to enable, False to disable. |
There was a problem hiding this comment.
FWIW, I believe that "True to enable, False to disable" is a unnecessary comment given that this config option is explicitly declared to be a boolean.
There was a problem hiding this comment.
Yes, the only valid values are True and False, but what do they do? The config setting is "profiling.on", and while it would seem obvious that "on" means "enabled" I decided to be explicit. Especially because the rest of the description uses the terms "enable" and "disable".
Take the first sentence:
Enable or disable the Sideboard profiler.
And the last sentence:
True to enable, False to disable.
No ambiguity there.
There was a problem hiding this comment.
Fair enough. I can't argue with "Explicit is better than implicit."
| # decorator will be aggregated over time. Individual profiler files will be | ||
| # created for each call, but the stats reported in each file will be the | ||
| # aggregate of all previous runs, plus the current run. This will smooth out | ||
| # how the profiler data changes over time, but it will be hard to guage the |
| 'ConfigurationError', 'parse_config', | ||
| 'is_listy', 'listify', 'serializer', 'cached_property', 'class_property', 'entry_point', | ||
| 'stopped', 'on_startup', 'on_shutdown', 'mainloop', 'ajax', 'renders_template', 'render_with_templates', | ||
| 'restricted', 'all_restricted', 'register_authenticator' |
There was a problem hiding this comment.
Funny this line was missing a comma, so I think the two items were concatenated and register_authenticatorDaemonTask was part of all.
Whoops! I had exactly the same thing happen in a different codebase at work recently. It's one of those typos you don't notice until it actually causes an error. Good catch!
...You get the same functionality we have here, but without an unwieldy all list.
Hmm, I'll have to think of that. I kind of like the way we have it now, because sideboard.lib is the place where you import non-SQLAlchemy Sideboard utilities, which means that there's a single file where you can look to find a complete list of everything that Sideboard exposes. That's a desirable property for a codebase to have IMO.
| profiling.on = boolean(default=True) | ||
| profiling.path = string(default="%(root)s/data/profiler") | ||
| profiling.aggregate = boolean(default=False) | ||
| profiling.strip_dirs = boolean(default=False) |
There was a problem hiding this comment.
I think it might be clearer if we put example values rather than transcribing the actual definition of the config options, e.g.
[cherrypy]
profiling.on = True
profiling.strip_dirs = False
profiling.aggregate = False
profiling.path = "%(root)s/data/profiler"
Having talked to a lot of developers who aren't familiar with configobj, I think putting example values like that will be less confusing for newcomers than putting the ConfigObj optionb definition syntax here.
|
|
||
| import cherrypy | ||
| from sideboard.config import config | ||
| from sideboard.lib import entry_point, listify |
There was a problem hiding this comment.
Silly nitpick: the suggested canonical way to import config is to say from sideboard.lib import config.
There was a problem hiding this comment.
Suggested by whom? Cause that sounds like crazy talk.
I mean, I'll change it. But time comes you convince me that importing config from lib makes sense, say your prayers because the end of days is upon us.
There was a problem hiding this comment.
Suggested by whom?
https://www.youtube.com/watch?v=O07NghuK2ow&t=3s
The idea is that anything which is importable by plugins should be imported from sideboard.lib. We explicitly expose sideboard.lib.config so as to allow plugins to access Sideboard's config in case they need to look something up.
I'm not dead-set on this, but in general I like the idea of having "the canonical place where X should be imported from", and for anything deliberately exposed to plugins then that place is under sideboard.lib.
| configspec.ini | ||
| """ | ||
| if config['cherrypy'].get('profiling.on', False): | ||
| profiling_path = config['cherrypy'].get('profiling.path', None) |
There was a problem hiding this comment.
Two notes:
-
See above about not needing to say
.gethere. -
What happens if someone sets an invalid path for
profiling_path?
There was a problem hiding this comment.
Internally this is just calling pstats.dump_stats(), so whatever that does with a bad path.
https://docs.python.org/3/library/profile.html#pstats.Stats.dump_stats
There was a problem hiding this comment.
I'll add a comment explaining that.
There was a problem hiding this comment.
That's fine - I'm definitely okay with a "garbage-in garbage-out" approach here.
|
|
||
| def __init__(self, path=None): | ||
| if not path: | ||
| path = os.path.join(os.path.dirname(__file__), 'profile') |
There was a problem hiding this comment.
This seems like a bad default, since this makes the default inside the sideboard/lib directory. Unless I'm misunderstanding, I think it would be better to just say this:
def __init__(self, path=config['cherrypy']['profiling.path']):I realize that in practice we're always passing a value, but leaving a nonsensical default value here seems bad.
There was a problem hiding this comment.
Ah, yeah, this was copied out of cherrypy verbatim. I can change it.
| is_test_running = True | ||
|
|
||
| # Prevent plugins from loading during tests | ||
| plugins_dir = '%(root)s/test_plugins' |
There was a problem hiding this comment.
Minor suggestion to make that more self-documenting: use does_not_exist instead of test_plugins.
|
|
||
| [cherrypy] | ||
| engine.autoreload.on = False | ||
| profiling.on = 'False' |
There was a problem hiding this comment.
Minor style suggestion: use False instead of 'False'.
| deps= -rrequirements.txt | ||
| commands= | ||
| coverage run --source sideboard -m py.test {posargs} | ||
| coverage run --source sideboard -m py.test {posargs} sideboard |
This is based on the built-in cherrypy profiler, but with some improvements.
The web UI looks like this: