Repository: bugsnag/bugsnag-python

Issue:
No way to specify the app_type information
### Description

The API accepts a `app.type` field in the `events` array, but the Python lib doesn't provide a way to specify it.

### Issue

One should be able to specify this field using the official Python client.


Comments:

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 23433c2..48c4bf0 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,9 @@ Changelog
 * Support forcing an individual event to be sent synchronously to Bugsnag.
   Given a configuration where asynchronous=True (the default setting), use
   `notify(ex, asynchronous=False)` to block until the event is delivered.
+* Support configuring app type, which is a searchable field on the Bugsnag
+  dashboard. Set `Configuration.app_type` to add a `type` property to the app
+  metadata of an event.
 
 ### Fixes
 
diff --git a/bugsnag/configuration.py b/bugsnag/configuration.py
index 7084011..4bc2d78 100644
--- a/bugsnag/configuration.py
+++ b/bugsnag/configuration.py
@@ -68,6 +68,7 @@ class Configuration(_BaseConfiguration):
         self.delivery = create_default_delivery()
         self.lib_root = get_python_lib()
         self.project_root = os.getcwd()
+        self.app_type = None
         self.app_version = None
         self.params_filters = ["password", "password_confirmation", "cookie",
                                "authorization"]
@@ -111,7 +112,7 @@ class Configuration(_BaseConfiguration):
             'ignore_classes', 'lib_root', 'notify_release_stages',
             'params_filters', 'project_root', 'proxy_host', 'release_stage',
             'send_code', 'session_endpoint', 'traceback_exclude_modules',
-            'use_ssl',
+            'use_ssl', 'app_type',
         ]
 
         for option_name in options.keys():
@@ -134,6 +135,18 @@ class Configuration(_BaseConfiguration):
     def api_key(self, value):
         self._api_key = value
 
+    @property
+    def app_type(self):
+        """
+        Category for the current application or task
+        """
+        return self._app_type
+
+    @app_type.setter  # type: ignore
+    @validate_str_setter
+    def app_type(self, value):
+        self._app_type = value
+
     @property
     def app_version(self):
         """
diff --git a/bugsnag/notification.py b/bugsnag/notification.py
index aaab7c5..8a18c82 100644
--- a/bugsnag/notification.py
+++ b/bugsnag/notification.py
@@ -50,6 +50,7 @@ class Notification(object):
 
         self.release_stage = get_config("release_stage")
         self.app_version = get_config("app_version")
+        self.app_type = get_config("app_type")
         self.hostname = get_config("hostname")
         self.runtime_versions = get_config("runtime_versions")
         self.send_code = get_config("send_code")
@@ -232,7 +233,10 @@ class Notification(object):
                 "severityReason": self.severity_reason,
                 "unhandled": self.unhandled,
                 "releaseStage": self.release_stage,
-                "appVersion": self.app_version,
+                "app": {
+                    "version": self.app_version,
+                    "type": self.app_type,
+                },
                 "context": self.context,
                 "groupingHash": self.grouping_hash,
                 "exceptions": [{


Test patch:
diff --git a/tests/integrations/conftest.py b/tests/integrations/conftest.py
index 7edb31b..9425c21 100644
--- a/tests/integrations/conftest.py
+++ b/tests/integrations/conftest.py
@@ -11,4 +11,5 @@ def bugsnag_server():
 
     yield server
 
+    bugsnag.configure(app_type=None)
     server.shutdown()
diff --git a/tests/test_configuration.py b/tests/test_configuration.py
index 47d0603..be9b29c 100644
--- a/tests/test_configuration.py
+++ b/tests/test_configuration.py
@@ -138,6 +138,21 @@ class TestConfiguration(unittest.TestCase):
             assert len(record) == 1
             assert c.endpoint == 'https://notify.example.com'
 
+    def test_validate_app_type(self):
+        c = Configuration()
+        assert c.app_type is None
+        with pytest.warns(RuntimeWarning) as record:
+            c.configure(app_type=[])
+
+            assert len(record) == 1
+            assert str(record[0].message) == 'app_type should be str, got list'
+            assert c.app_type is None
+
+            c.configure(app_type='rq')
+
+            assert len(record) == 1
+            assert c.app_type == 'rq'
+
     def test_validate_app_version(self):
         c = Configuration()
         with pytest.warns(RuntimeWarning) as record:
diff --git a/tests/test_notification.py b/tests/test_notification.py
index 20b10c6..3997dee 100644
--- a/tests/test_notification.py
+++ b/tests/test_notification.py
@@ -159,3 +159,26 @@ class TestNotification(unittest.TestCase):
         device = payload['events'][0]['device']
         self.assertEqual('test_host_name', device['hostname'])
         self.assertEqual('9.9.9', device['runtimeVersions']['python'])
+
+    def test_default_app_type(self):
+        """
+        app_type is None by default
+        """
+        config = Configuration()
+        notification = Notification(Exception("oops"), config, {})
+        payload = json.loads(notification._payload())
+        app = payload['events'][0]['app']
+
+        assert app['type'] is None
+
+    def test_configured_app_type(self):
+        """
+        It should include app type if specified
+        """
+        config = Configuration()
+        config.configure(app_type='rq')
+        notification = Notification(Exception("oops"), config, {})
+        payload = json.loads(notification._payload())
+        app = payload['events'][0]['app']
+
+        assert app['type'] == 'rq'
diff --git a/tests/test_notify.py b/tests/test_notify.py
index 185b0a8..9b90d9c 100644
--- a/tests/test_notify.py
+++ b/tests/test_notify.py
@@ -80,7 +80,7 @@ class TestBugsnag(IntegrationTest):
         bugsnag.notify(ScaryException('unexpected failover'))
         json_body = self.server.received[0]['json_body']
         event = json_body['events'][0]
-        self.assertEqual('343.2.10', event['appVersion'])
+        self.assertEqual('343.2.10', event['app']['version'])
 
     def test_notify_override_context(self):
         bugsnag.notify(ScaryException('unexpected failover'),
@@ -189,6 +189,31 @@ class TestBugsnag(IntegrationTest):
         bugsnag.notify(ScaryException('unexpected failover'))
         self.assertEqual(0, len(self.server.received))
 
+    def test_notify_custom_app_type(self):
+        bugsnag.notify(ScaryException('unexpected failover'), app_type='work')
+        json_body = self.server.received[0]['json_body']
+        event = json_body['events'][0]
+        self.assertEqual('work', event['app']['type'])
+
+    def test_notify_callback_app_type(self):
+
+        def callback(report):
+            report.app_type = 'whopper'
+
+        bugsnag.configure(app_type='rq')
+        bugsnag.before_notify(callback)
+        bugsnag.notify(ScaryException('unexpected failover'))
+        json_body = self.server.received[0]['json_body']
+        event = json_body['events'][0]
+        self.assertEqual('whopper', event['app']['type'])
+
+    def test_notify_configured_app_type(self):
+        bugsnag.configure(app_type='rq')
+        bugsnag.notify(ScaryException('unexpected failover'))
+        json_body = self.server.received[0]['json_body']
+        event = json_body['events'][0]
+        self.assertEqual('rq', event['app']['type'])
+
     def test_notify_sends_when_before_notify_throws(self):
 
         def callback(report):