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>
+
+<%def name="javascripts()">
+ ${parent.javascripts()}
+ ${handle_refresh_frames()}
+
+%def>
+
+##
+## 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>
+
+<%def name="body()">
+%for (message, status) in messages:
+ ${render_large_message( message, status )}
+%endfor
+%def>
+
+## Render large message.
+<%def name="render_large_message( message, status )">
+
${_(message)}
+%def>
+
+## Render a message
+<%def name="render_msg( msg, status='done' )">
+ ${_(msg)}
+
+%def>
+