← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] lp:~lifeless/python-oops-tools/amqp into lp:python-oops-tools

 

Robert Collins has proposed merging lp:~lifeless/python-oops-tools/amqp into lp:python-oops-tools.

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

For more details, see:
https://code.launchpad.net/~lifeless/python-oops-tools/amqp/+merge/79505

Build on the recent improvements in oops-datedir-repo and oops-amqp to add an amqp queue worker that will take oopses from amqp and load them straight into OOPS-tools. Also fix docs (0.6 is the current release) and stop telling folk to edit product.cfg in-place.
-- 
https://code.launchpad.net/~lifeless/python-oops-tools/amqp/+merge/79505
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~lifeless/python-oops-tools/amqp into lp:python-oops-tools.
=== modified file 'setup.py'
--- setup.py	2011-10-13 20:18:51 +0000
+++ setup.py	2011-10-16 22:38:22 +0000
@@ -57,6 +57,7 @@
         'launchpadlib',
         'lazr.config',
         'oops',
+        'oops-amqp',
         'oops-datedir-repo',
         'pytz',
         'setuptools',
@@ -79,6 +80,7 @@
     ),
     entry_points=dict(
         console_scripts=[ # `console_scripts` is a magic name to setuptools
+            'amqp2disk = oopstools.scripts.amqp2disk:main',
             'analyse_error_reports = oopstools.scripts.analyse_error_reports:main',
             'load_sample_data = oopstools.scripts.load_sample_data:main',
             'update_db = oopstools.scripts.update_db:main',

=== modified file 'src/oopstools/NEWS.txt'
--- src/oopstools/NEWS.txt	2011-10-13 20:18:51 +0000
+++ src/oopstools/NEWS.txt	2011-10-16 22:38:22 +0000
@@ -2,8 +2,13 @@
 NEWS for oopstools
 ===================
 
-0.5 (UNRELEASED)
-================
+NEXT
+====
+
+* Added AMQP support via the bin/amqp2disk script. (Robert Collins)
+
+0.6
+===
 
 * Initial release
 

=== modified file 'src/oopstools/README.txt'
--- src/oopstools/README.txt	2011-10-13 20:18:51 +0000
+++ src/oopstools/README.txt	2011-10-16 22:38:22 +0000
@@ -51,19 +51,21 @@
 Deployment using mod_wsgi
 =========================
 
-Update the production.cfg file with your database configuration and the paths
-to your OOPS directories:
+Create a custom cfg file - start with production.cfg and take a copy. Update
+your copy with your database configuration and the paths to your OOPS
+directories:
 
 [configuration]
 db-name = /path/to/your/oops.db
 index-template = 'index.html'
-oopsdir = /path/to/oops/reports
+oopsdir = /path/where/rsynced/oopses/are/found
+    /another/such/path
 
 Update settings.py setting a custom SECRET_KEY
 
 To deploy oops tools make sure all the dependecies are installed.
 
- * bin/buildout -c production.cfg
+ * bin/buildout -c yourfilename.cfg
 
  * Run bin/django syncdb
 
@@ -71,6 +73,15 @@
 
  * Copy apache/oops-tools.dev.mod_wsgi to /etc/apache2/sites-available/
 
+AMQP Integration
+================
+
+The script bin/amqp2disk is an AMQP handler that will receive OOPS reports over
+AMQP and publish them locally to disk as well as loading the metadata directly
+into the oops-tools database. To use this you will need to config your OOPS
+creation to publish over AMQP. If you are using Python then the oops-amqp
+module will help you do this.
+
 Running locally
 ===============
 

=== modified file 'src/oopstools/oops/models.py'
--- src/oopstools/oops/models.py	2011-10-13 20:18:51 +0000
+++ src/oopstools/oops/models.py	2011-10-16 22:38:22 +0000
@@ -21,6 +21,8 @@
 import datetime
 import os.path
 
+from pytz import utc
+
 from django.db import models
 from django.db.models.signals import pre_save
 import oops_datedir_repo.serializer
@@ -278,7 +280,11 @@
     prefix = oops.get('reporter')
     if not prefix:
         # Legacy support for pre-reporter using OOPSes.
-        prefix = oops_re.match(oopsid).group('oopsprefix')
+        prefix_match = oops_re.match(oopsid)
+        if prefix_match is not None:
+            prefix = prefix_match.group('oopsprefix')
+        else:
+            prefix = 'UNKNOWN'
     prefix = prefix.upper()
     try:
         prefix = Prefix.objects.get(value__exact=prefix)
@@ -329,9 +335,10 @@
     if total_time < 0:
         total_time = 0
     # Get the oops infestation
-    exception_type = oops.get('type')
+    exception_type = oops.get('type') or ''
+    exception_value = oops.get('value') or ''
     exception_value = _normalize_exception_value(
-        exception_type, oops.get('value'), prefix)
+        exception_type, exception_value, prefix)
     try:
         infestation = Infestation.objects.get(
             exception_type__exact=exception_type,
@@ -361,10 +368,13 @@
         most_expensive_statement = conform(most_expensive_statement, 200)
     url = conform(oops.get('url') or '', MAX_URL_LEN)
     informational = oops.get('informational', 'False').lower() == 'true'
+    oops_date = oops.get('time')
+    if oops_date is None:
+        oops_date = datetime.datetime.now(utc)
     data.update(
         oopsid = oopsid,
         prefix = prefix,
-        date = oops.get('time').replace(microsecond=0),
+        date = oops_date.replace(microsecond=0),
         # Missing pageids are urls because that suits our queries.
         pageid = oops.get('topic') or url,
         url = url,
@@ -377,7 +387,28 @@
         is_bot = _robot_pat.search(data['user_agent']) is not None,
         statements_count = len(statements),
         )
-    return data, req_vars, statements, oops.get('tb_text')
+    return data, req_vars, statements, oops.get('tb_text') or ''
+
+
+def parsed_oops_to_model_oops(parsed_oops, pathname):
+    """Convert an oops report dict to an Oops object."""
+    data, req_vars, statements, traceback = _get_oops_tuple(parsed_oops)
+    data['pathname'] = pathname
+    res = Oops(**data)
+    res.appinstance = res.get_appinstance()
+    res.save()
+    # Get it again.  Otherwise we have discrepancies between old and
+    # new oops objects: old ones have unicode attributes, and new
+    # ones have string attributes, for instance.  Ideally the message
+    # conversion would have converted everything to unicode, but it
+    # doesn't easily.
+    res = Oops.objects.get(oopsid__exact=parsed_oops['id'])
+    res.parsed_oops = parsed_oops
+    res.req_vars = req_vars
+    res.statements = statements
+    res.traceback = traceback
+    res.save()
+    return res
 
 
 class Oops(models.Model):
@@ -432,21 +463,7 @@
         try:
             res = cls.objects.get(oopsid__exact=oopsid)
         except cls.DoesNotExist:
-            data, req_vars, statements, traceback = _get_oops_tuple(parsed_oops)
-            data['pathname'] = pathname
-            res = cls(**data)
-            res.appinstance = res.get_appinstance()
-            res.save()
-            # Get it again.  Otherwise we have discrepancies between old and
-            # new oops objects: old ones have unicode attributes, and new
-            # ones have string attributes, for instance.  Ideally the message
-            # conversion would have converted everything to unicode, but it
-            # doesn't easily.
-            res = cls.objects.get(oopsid__exact=oopsid)
-            res.parsed_oops = parsed_oops
-            res.req_vars = req_vars
-            res.statements = statements
-            res.traceback = traceback
+            res = parsed_oops_to_model_oops(parsed_oops, pathname)
         return res
 
     @readproperty

=== added file 'src/oopstools/oops/test/test_amqp2disk.py'
--- src/oopstools/oops/test/test_amqp2disk.py	1970-01-01 00:00:00 +0000
+++ src/oopstools/oops/test/test_amqp2disk.py	2011-10-16 22:38:22 +0000
@@ -0,0 +1,40 @@
+# Copyright 2005-2011 Canonical Ltd.  All rights reserved.
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+
+import os.path
+
+import bson
+from fixtures import TempDir
+from testtools import TestCase
+
+from oopstools.oops.models import Oops
+from oopstools.scripts import amqp2disk
+
+
+class TestOOPSConfig(TestCase):
+
+    def test_publishes_disk_and_DB(self):
+        self.root_dir = self.useFixture(TempDir()).path
+        config = amqp2disk.make_amqp_config(self.root_dir)
+        orig_report = {'id': '12345'}
+        report = dict(orig_report)
+        ids = config.publish(report)
+        self.assertEqual(['12345', '12345'], ids)
+        with open(report['datedir_repo_filepath'], 'rb') as fp:
+            disk_report = bson.loads(fp.read())
+        self.assertEqual(disk_report, orig_report)
+        model_report = Oops.objects.get(oopsid='12345')
+        self.assertNotEqual(None, model_report)

=== added file 'src/oopstools/scripts/amqp2disk.py'
--- src/oopstools/scripts/amqp2disk.py	1970-01-01 00:00:00 +0000
+++ src/oopstools/scripts/amqp2disk.py	2011-10-16 22:38:22 +0000
@@ -0,0 +1,137 @@
+# Copyright 2011 Canonical Ltd.  All rights reserved.
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+# Receive OOPS reports from AMQP and publish them into the oops-tools
+# repository.
+
+__metaclass__ = type
+
+import sys
+import optparse
+import StringIO
+from textwrap import dedent
+
+import amqplib.client_0_8 as amqp
+from oops import Config
+import oops_amqp
+import oops_datedir_repo
+
+from oopstools.oops.helpers import parsedate, load_prefixes
+from oopstools.oops.models import (
+    Oops,
+    parsed_oops_to_model_oops,
+    Prefix,
+    Report,
+    )
+from oopstools.oops.oopsstore import OopsStore
+from oopstools.oops import dbsummaries
+from oopstools.oops.summaries import (
+    WebAppErrorSummary,
+    CheckwatchesErrorSummary,
+    CodeHostingWithRemoteSectionSummary,
+    GenericErrorSummary,
+)
+
+
+def main(argv=None):
+    if argv is None:
+        argv=sys.argv
+    usage = dedent("""\
+        %prog [options]
+
+        The following options must be supplied:
+         --output
+         --host
+         --username
+         --password
+         --vhost
+         --queue
+
+        e.g.
+        amqp2disk --output /srv/oops-tools/amqpoopses --host "localhost:3472" \\
+            --username "guest" --password "guest" --vhost "/" --queue "oops"
+
+        The AMQP environment should be setup in advance with a persistent queue
+        bound to your exchange : using transient queues would allow OOPSes to
+        be lost if the amqp2disk process were to be shutdown for a non-trivial
+        duration. The --bind-to option will cause the queue to be created and
+        bound to the given exchange. This is only needed the first time as it
+        is created persistently.
+        """)
+    description = "Load OOPS reports into oops-tools from AMQP."
+    parser = optparse.OptionParser(
+        description=description, usage=usage)
+    parser.add_option('--output', help="Root directory to store OOPSes in.")
+    parser.add_option('--host', help="AQMP host / host:port.")
+    parser.add_option('--username', help="AQMP username.")
+    parser.add_option('--password', help="AQMP password.")
+    parser.add_option('--vhost', help="AMQP vhost.")
+    parser.add_option('--queue', help="AMQP queue name.")
+    parser.add_option(
+        '--bind-to', help="AMQP exchange to bind to (only needed once).")
+    options, args = parser.parse_args(argv[1:])
+    def needed(optname):
+        if getattr(options, optname, None) is None:
+            raise ValueError('option "%s" must be supplied' % optname)
+    needed('host')
+    needed('output')
+    needed('username')
+    needed('password')
+    needed('vhost')
+    needed('queue')
+    connection = amqp.Connection(host=options.host, userid=options.username,
+        password=options.password, virtual_host=options.vhost)
+    channel = connection.channel()
+    if options.bind_to:
+        channel.queue_declare(options.queue, durable=True, auto_delete=False)
+        channel.queue_bind(options.queue, options.bind_to)
+    config = make_amqp_config(options.output)
+    receiver = oops_amqp.Receiver(config, channel, options.queue)
+    try:
+        receiver.run_forever()
+    except KeyboardInterrupt:
+        pass
+
+
+def db_publisher(report):
+    """Publish OOPS reports to the oops-tools django store."""
+    # the first publisher will either inherit or assign, so this should be
+    # impossible.
+    assert report['id'] is not None
+    # Some fallback methods could lead to duplicate paths into the DB: exit
+    # early if the OOPS is already loaded.
+    try:
+        res = Oops.objects.get(oopsid__exact=report['id'])
+    except Oops.DoesNotExist:
+        res = parsed_oops_to_model_oops(
+            report, report['datedir_repo_filepath'])
+        return res.oopsid
+    return None
+
+
+def make_amqp_config(output_dir):
+    """Create an OOPS Config for republishing amqp OOPSes.
+
+    An OOPS published to this config will be written to disk and then loaded
+    into the database.
+
+    :param output_dir: The directory to write OOPSes too.
+    """
+    config = Config()
+    disk_publisher = oops_datedir_repo.DateDirRepo(
+        output_dir, inherit_id=True, stash_path=True)
+    config.publishers.append(disk_publisher.publish)
+    config.publishers.append(db_publisher)
+    return config

=== modified file 'versions.cfg'
--- versions.cfg	2011-10-13 20:18:51 +0000
+++ versions.cfg	2011-10-16 22:38:22 +0000
@@ -19,8 +19,9 @@
 launchpadlib = 1.6.0
 lazr.config = 1.1.3
 mechanize = 0.1.11
-oops = 0.0.7
-oops-datedir-repo = 0.0.7
+oops = 0.0.9
+oops-amqp = 0.0.1
+oops-datedir-repo = 0.0.9
 setuptools = 0.6c11
 z3c.recipe.filetemplate = 2.0.3
 z3c.recipe.sphinxdoc = 0.0.8