diff --git a/config/error_report.xml.sample b/config/error_report.xml.sample new file mode 100644 index 00000000000..8b0d1a96c6a --- /dev/null +++ b/config/error_report.xml.sample @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + diff --git a/lib/galaxy/app.py b/lib/galaxy/app.py index b735e8de8ba..90083e12f8e 100644 --- a/lib/galaxy/app.py +++ b/lib/galaxy/app.py @@ -27,6 +27,7 @@ from galaxy.tools.cache import ( ToolShedRepositoryCache ) from galaxy.jobs import metrics as job_metrics +from galaxy.tools.error_reports import ErrorReports from galaxy.web.proxy import ProxyManager from galaxy.web.stack import application_stack_instance from galaxy.queue_worker import GalaxyQueueWorker @@ -102,6 +103,9 @@ class UniverseApplication( object, config.ConfiguresGalaxyMixin ): # config so per-destination modifications can be made. self.job_metrics = job_metrics.JobMetrics( self.config.job_metrics_config_file, app=self ) + # Initialize error report sinks. + self.error_reports = ErrorReports( self.config.error_report_file, app=self ) + # Initialize the job management configuration self.job_config = jobs.JobConfiguration(self) diff --git a/lib/galaxy/config.py b/lib/galaxy/config.py index e9cc67bdb35..e96eae1465c 100644 --- a/lib/galaxy/config.py +++ b/lib/galaxy/config.py @@ -43,6 +43,7 @@ PATH_DEFAULTS = dict( job_config_file=['config/job_conf.xml', 'job_conf.xml'], tool_destinations_config_file=['config/tool_destinations.yml', 'config/tool_destinations.yml.sample'], job_metrics_config_file=['config/job_metrics_conf.xml', 'job_metrics_conf.xml', 'config/job_metrics_conf.xml.sample'], + error_report_file=['config/error_report.xml', 'error_report.xml', 'config/error_report.xml.sample'], dependency_resolvers_config_file=['config/dependency_resolvers_conf.xml', 'dependency_resolvers_conf.xml'], job_resource_params_file=['config/job_resource_params_conf.xml', 'job_resource_params_conf.xml'], migrated_tools_config=['migrated_tools_conf.xml', 'config/migrated_tools_conf.xml'], diff --git a/lib/galaxy/jobs/__init__.py b/lib/galaxy/jobs/__init__.py index a29244ec283..2912afa7ab4 100644 --- a/lib/galaxy/jobs/__init__.py +++ b/lib/galaxy/jobs/__init__.py @@ -1028,7 +1028,7 @@ class JobWrapper( object, HasResourceParameters ): # the partial files to the object store regardless of whether job.state == DELETED self.__update_output(job, dataset, clean_only=True) - self._report_error_to_sentry() + self._report_error() # Perform email action even on failure. for pja in [pjaa.post_job_action for pjaa in job.post_job_actions if pjaa.post_job_action.action_type == "EmailAction"]: ActionBox.execute(self.app, self.sa_session, pja, job) @@ -1437,7 +1437,7 @@ class JobWrapper( object, HasResourceParameters ): self.sa_session.flush() log.debug( 'job %d ended (finish() executed in %s)' % (self.job_id, finish_timer) ) if job.state == job.states.ERROR: - self._report_error_to_sentry() + self._report_error() cleanup_job = self.cleanup_job delete_files = cleanup_job == 'always' or ( job.state == job.states.OK and cleanup_job == 'onsuccess' ) self.cleanup( delete_files=delete_files ) @@ -1809,27 +1809,11 @@ class JobWrapper( object, HasResourceParameters ): return self.tool.requires_setting_metadata return False - def _report_error_to_sentry( self ): + def _report_error( self ): job = self.get_job() tool = self.app.toolbox.get_tool(job.tool_id, tool_version=job.tool_version) or None - if self.app.sentry_client and job.state == job.states.ERROR: - self.app.sentry_client.capture( - 'raven.events.Message', - message="Galaxy Job Error: %s v.%s" % (job.tool_id, job.tool_version), - extra={ - 'info' : job.info, - 'id' : job.id, - 'command_line' : job.command_line, - 'stderr' : job.stderr, - 'traceback': job.traceback, - 'exit_code': job.exit_code, - 'stdout': job.stdout, - 'handler': job.handler, - 'user': self.user, - 'tool_version': job.tool_version, - 'tool_xml': tool.config_file if tool else None - } - ) + for dataset in job.output_datasets: + self.app.error_reports.default_error_sink.submit_report(dataset, job, tool, user_submission=False) class TaskWrapper(JobWrapper): diff --git a/lib/galaxy/tools/error_reports/__init__.py b/lib/galaxy/tools/error_reports/__init__.py new file mode 100644 index 00000000000..fc4438f8e67 --- /dev/null +++ b/lib/galaxy/tools/error_reports/__init__.py @@ -0,0 +1,64 @@ +"""This module defines the error reporting framework for Galaxy jobs. +""" +import collections +import logging +import os + +from galaxy import util +from galaxy.util import plugin_config + +log = logging.getLogger(__name__) + + +class ErrorReports(object): + """Load and store a collection of :class:`ErrorSink` objects.""" + + def __init__(self, conf_file=None, **kwargs): + """Load :class:`ErrorSink` objects from specified configuration file.""" + self.plugin_classes = self.__plugins_dict() + self.default_error_sink = ErrorSink.from_file(self.plugin_classes, conf_file, **kwargs) + self.error_sinks = collections.defaultdict(lambda: self.default_error_sink) + + def __plugins_dict(self): + import galaxy.tools.error_reports.sinks + return plugin_config.plugins_dict(galaxy.tools.error_reports.sinks, 'plugin_type') + + +class NullErrorSink(object): + + def submit_report(self, dataset, job, tool, **kwargs): + return "Submitted Bug Report" + + +NULL_ERROR_SINK = NullErrorSink() + + +class ErrorSink(object): + + def __init__(self, plugin_classes, plugins_source, **kwargs): + self.extra_kwargs = kwargs + self.plugin_classes = plugin_classes + self.plugins = self.__plugins_from_source(plugins_source) + + def submit_report(self, dataset, job, tool, user_submission=False, **kwargs): + responses = [] + for plugin in self.plugins: + if user_submission == plugin.user_submission: + try: + response = plugin.submit_report(dataset, job, tool, **kwargs) + log.debug("Bug report plugin %s generated response %s", plugin, response) + if plugin.verbose and response: + responses.append(response) + except Exception: + log.exception("Failed to generate submit_report commands for plugin %s", plugin) + return responses + + def __plugins_from_source(self, plugins_source): + return plugin_config.load_plugins(self.plugin_classes, plugins_source, self.extra_kwargs) + + @staticmethod + def from_file(plugin_classes, conf_file, **kwargs): + if not conf_file or not os.path.exists(conf_file): + return NULL_ERROR_SINK + plugins_source = plugin_config.plugin_source_from_path(conf_file) + return ErrorSink(plugin_classes, plugins_source, **kwargs) diff --git a/lib/galaxy/tools/error_reports/sinks/__init__.py b/lib/galaxy/tools/error_reports/sinks/__init__.py new file mode 100644 index 00000000000..9fddf47480c --- /dev/null +++ b/lib/galaxy/tools/error_reports/sinks/__init__.py @@ -0,0 +1,26 @@ +"""This module describes the abstract interface for :class:`InstrumentPlugin`. + +These are responsible for collecting and formatting a coherent set of metrics. +""" +import os.path +from abc import ( + ABCMeta, + abstractmethod +) + +import six + + +@six.add_metaclass(ABCMeta) +class ErrorSinkPlugin( object ): + """Describes how to send bug reports to various locations.""" + + @property + @abstractmethod + def plugin_type( self ): + """Short string providing labelling this plugin """ + + def submit_report( self, dataset, job, tool, user_submission=False, **kwargs ): + """Submit the bug report and render a string to be displayed to the user. + """ + return None diff --git a/lib/galaxy/tools/error_reports/sinks/email.py b/lib/galaxy/tools/error_reports/sinks/email.py new file mode 100644 index 00000000000..2b7866b299f --- /dev/null +++ b/lib/galaxy/tools/error_reports/sinks/email.py @@ -0,0 +1,34 @@ +"""The module describes the ``email`` error sink plugin.""" +from __future__ import absolute_import + +import logging + +from ..sinks import ErrorSinkPlugin +from galaxy.tools.errors import EmailErrorReporter +from galaxy.util import string_as_bool + +log = logging.getLogger( __name__ ) + + +class EmailPlugin( ErrorSinkPlugin ): + """Send error report as an email + """ + plugin_type = "email" + + def __init__( self, **kwargs ): + self.app = kwargs['app'] + self.verbose = string_as_bool(kwargs.get('verbose', True)) + self.user_submission = string_as_bool(kwargs.get('user_submission', True)) + + def submit_report( self, dataset, job, tool, **kwargs ): + """Send report as an email + """ + try: + error_reporter = EmailErrorReporter( dataset.id, self.app ) + error_reporter.send_report( user=job.get_user(), email=kwargs.get('email', None), message=kwargs.get('message', None) ) + return ( "Your error report has been sent", "done" ) + except Exception as e: + return ( "An error occurred sending the report by email: %s" % str( e ), "error" ) + + +__all__ = ( 'EmailPlugin', ) diff --git a/lib/galaxy/tools/error_reports/sinks/json.py b/lib/galaxy/tools/error_reports/sinks/json.py new file mode 100644 index 00000000000..0d0d3f66343 --- /dev/null +++ b/lib/galaxy/tools/error_reports/sinks/json.py @@ -0,0 +1,56 @@ +"""The module describes the ``json`` error sink plugin.""" +from __future__ import absolute_import + +import json +import logging +import os +import tempfile + +from ..sinks import ErrorSinkPlugin +from galaxy.util import string_as_bool + +log = logging.getLogger( __name__ ) + + +class JsonPlugin( ErrorSinkPlugin ): + """Write error report to a JSON file. + """ + plugin_type = "json" + + def __init__( self, **kwargs ): + self.app = kwargs['app'] + self.verbose = string_as_bool(kwargs.get('verbose', False)) + self.user_submission = string_as_bool(kwargs.get('user_submission', False)) + self.report_directory = kwargs.get( "directory", tempfile.gettempdir() ) + if not os.path.exists(self.report_directory): + os.makedirs(self.report_directory) + + def submit_report( self, dataset, job, tool, **kwargs ): + """Write the report to a json file. + """ + path = os.path.join( self.report_directory, str(dataset.id)) + with open( path, 'w' ) as handle: + data = { + 'info' : job.info, + 'id' : job.id, + 'command_line' : job.command_line, + 'stderr' : job.stderr, + 'traceback': job.traceback, + 'exit_code': job.exit_code, + 'stdout': job.stdout, + 'handler': job.handler, + 'user': job.get_user().to_dict(), + 'tool_version': job.tool_version, + 'tool_xml': str(tool.config_file) if tool else None + } + if 'email' in kwargs: + data['email'] = kwargs['email'] + + if 'message' in kwargs: + data['message'] = kwargs['message'] + + json.dump(data, handle, indent=2) + return ( 'Wrote error report to %s' % path, 'done' ) + + +__all__ = ( 'JsonPlugin', ) diff --git a/lib/galaxy/tools/error_reports/sinks/sentry.py b/lib/galaxy/tools/error_reports/sinks/sentry.py new file mode 100644 index 00000000000..78df66afa33 --- /dev/null +++ b/lib/galaxy/tools/error_reports/sinks/sentry.py @@ -0,0 +1,50 @@ +"""The module describes the ``sentry`` error sink plugin.""" +import logging + +from ..sinks import ErrorSinkPlugin +from galaxy.util import string_as_bool + +log = logging.getLogger( __name__ ) + + +class SentryPlugin( ErrorSinkPlugin ): + """Send error report to Sentry. + """ + plugin_type = "sentry" + + def __init__( self, **kwargs ): + self.app = kwargs['app'] + self.verbose = string_as_bool(kwargs.get('verbose', False)) + self.user_submission = string_as_bool(kwargs.get('user_submission', False)) + + def submit_report( self, dataset, job, tool, **kwargs ): + """Submit the error report to sentry + """ + if self.app.sentry_client and job.state == job.states.ERROR: + extra = { + 'info' : job.info, + 'id' : job.id, + 'command_line' : job.command_line, + 'stderr' : job.stderr, + 'traceback': job.traceback, + 'exit_code': job.exit_code, + 'stdout': job.stdout, + 'handler': job.handler, + 'user': job.get_user(), + 'tool_version': job.tool_version, + 'tool_xml': tool.config_file if tool else None + } + if 'email' in kwargs: + extra['email'] = kwargs['email'] + + if 'message' in kwargs: + extra['message'] = kwargs['message'] + + self.app.sentry_client.capture( + 'raven.events.Message', + message="Galaxy Job Error: %s v.%s" % (job.tool_id, job.tool_version), + extra=extra, + ) + + +__all__ = ( 'SentryPlugin', ) diff --git a/lib/galaxy/web/framework/webapp.py b/lib/galaxy/web/framework/webapp.py index 6e13934c0e6..4f5a6688f52 100644 --- a/lib/galaxy/web/framework/webapp.py +++ b/lib/galaxy/web/framework/webapp.py @@ -853,6 +853,18 @@ class GalaxyWebTransaction( base.DefaultWebTransaction, """ return self.fill_template( "message.mako", status=type, message=message, refresh_frames=refresh_frames, cont=cont, use_panels=use_panels, active_view=active_view ) + def show_messages( self, messages, refresh_frames=[], cont=None, use_panels=False, active_view="" ): + """ + Convenience method for displaying a simple page with a multiple messages. + + `messages`: list of tuple (message, type) where type is one of "error", + "warning", "info", or "done" + + `refresh_frames`: names of frames in the interface that should be + refreshed when the message is displayed + """ + return self.fill_template( "messages.mako", messages=messages, refresh_frames=refresh_frames, cont=cont, use_panels=use_panels, active_view=active_view ) + def show_error_message( self, message, refresh_frames=[], use_panels=False, active_view="" ): """ Convenience method for displaying an error message. See `show_message`. diff --git a/lib/galaxy/webapps/galaxy/controllers/dataset.py b/lib/galaxy/webapps/galaxy/controllers/dataset.py index 41d34889b6d..83dbb7da701 100644 --- a/lib/galaxy/webapps/galaxy/controllers/dataset.py +++ b/lib/galaxy/webapps/galaxy/controllers/dataset.py @@ -165,12 +165,12 @@ class DatasetInterface( BaseUIController, UsesAnnotations, UsesItemRatings, Uses biostar_report = 'biostar' in str( kwd.get( 'submit_error_report') ).lower() if biostar_report: return trans.response.send_redirect( url_for( controller='biostar', action='biostar_tool_bug_report', hda=id, email=email, message=message ) ) - try: - error_reporter = EmailErrorReporter( id, trans.app ) - error_reporter.send_report( user=trans.user, email=email, message=message ) - return trans.show_ok_message( "Your error report has been sent" ) - except Exception as e: - return trans.show_error_message( "An error occurred sending the report by email: %s" % str( e ) ) + + dataset = trans.sa_session.query( trans.app.model.HistoryDatasetAssociation ).get( self.decode_id( id ) ) + job = self._get_job_for_dataset( trans, id ) + tool = trans.app.toolbox.get_tool(job.tool_id, tool_version=job.tool_version) or None + messages = trans.app.error_reports.default_error_sink.submit_report(dataset, job, tool, user_submission=True, email=email, message=message) + return trans.show_messages(messages) @web.expose def default(self, trans, dataset_id=None, **kwd): diff --git a/templates/messages.mako b/templates/messages.mako new file mode 100644 index 00000000000..e0025821528 --- /dev/null +++ b/templates/messages.mako @@ -0,0 +1,66 @@ +<%! + def inherit(context): + if context.get('use_panels'): + if context.get('webapp'): + app_name = context.get('webapp') + elif context.get('app'): + app_name = context.get('app').name + else: + app_name = 'galaxy' + return '/webapps/%s/base_panels.mako' % app_name + else: + return '/base.mako' +%> +<%inherit file="${inherit(context)}"/> + +<%namespace file="/refresh_frames.mako" import="handle_refresh_frames" /> + +<% _=n_ %> + +<%def name="init()"> +<% + self.has_left_panel=False + self.has_right_panel=False + self.active_view=active_view + self.message_box_visible=False +%> + + +<%def name="javascripts()"> + ${parent.javascripts()} + ${handle_refresh_frames()} + + + +## +## Override methods from base.mako and base_panels.mako +## + +<%def name="center_panel()"> +%for (message, status) in messages: + ${render_large_message( message, status )} +%endfor + + +<%def name="body()"> +%for (message, status) in messages: + ${render_large_message( message, status )} +%endfor + + +## Render large message. +<%def name="render_large_message( message, status )"> +
${_(message)}
+ + +## Render a message +<%def name="render_msg( msg, status='done' )"> +
${_(msg)}
+
+ +