Repository: honeybadger-io/honeybadger-python (language: python)

Issue:
Celery Integration
A customer wasn't sure how to integrate Honeybadger with Django and Celery:

https://docs.celeryproject.org/en/stable/django/

These items are open to anyone who knows how to take this on:

- [ ] Reply with a code sample (or sample app/repo) of how to do it manually
- [ ] Add an official integration, if possible
- [ ] Otherwise, add the manual integration instructions to the Honeybadger docs

<img src="https://front.com/assets/img/favicons/favicon-32x32.png" height="16" width="16" alt="Front logo" /> [Front conversations](https://app.frontapp.com/open/top_6p6g1)


Discussion / hints:
So i tried this out and noticed the problem is very similar to that of AWS lambda. i.e honeybadger unable to override  `sys.excepthook` directly. The solution will take similar approach too
Fixing this next..
@Kelvin4664 can you close out any issues that are completed in [this milestone](https://github.com/honeybadger-io/honeybadger-python/milestone/1) first? If there are any issue remaining, I'd like to get those finished up so that I can cut a new release with the new AWS Lambda integration.
Oh.. sure!
They're all done that's why i picked up this one.
Will close them now.
A way to do this could be using [Celery's signals](https://docs.celeryproject.org/en/stable/userguide/signals.html#task-failure).
> Celery's signals

Good point @stefanondisponibile
But i fear failure may be critical to the point where failure signals are not sent. What do you think?
yeah, good point too @Kelvin4664, I think what you're suggesting may happen.
What you think of [this](https://github.com/getsentry/sentry-python/blob/4c09f3203d6d19789c6fa729a2e46557ad4ea913/sentry_sdk/integrations/celery.py) approach instead?

Fix patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index ab9baa9..1e321b8 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,8 @@ CHANGELOG](http://keepachangelog.com/) for how to update this file. This project
 adheres to [Semantic Versioning](http://semver.org/).
 
 ## [Unreleased]
+### Added
+- Add Celery integration. (#124)
 
 ## [0.13.0] - 2022-11-11
 
diff --git a/README.md b/README.md
index 96d00a4..7e2a394 100644
--- a/README.md
+++ b/README.md
@@ -222,6 +222,32 @@ app = Starlette()
 app.add_middleware(contrib.ASGIHoneybadger)
 ```
 
+### Celery
+
+A Celery extension is available for initializing and configuring Honeybadger: `honeybadger.contrib.celery.CeleryHoneybadger`. The extension adds the following information to reported exceptions:
+
+- **component**: The module that the task is defined at.
+- **action**: The name of the task.
+- **params**: The arguments and keyword arguments passed to the task.
+- **context**: A dictionary containing the following:
+  - **task_id**: The id of the current task.
+  - **retries**: The number of retries that have been attempted.
+  - **max_retries**: The maximum number of retries that will be attempted.
+
+#### Example
+
+```python
+from celery import Celery
+from honeybadger.contrib import CeleryHoneybadger
+
+app = Celery(__name__)
+app.conf.update(
+  HONEYBADGER_API_KEY= '<your key>',
+  HONEYBADGER_ENVIRONMENT= 'production'
+)
+CeleryHoneybadger(app, report_exceptions=True)
+```
+  
 ### Other frameworks / plain Python app
 
 Django and Flask are the only explicitly supported frameworks at the moment. For other frameworks (tornado, web2py, etc.) or a plain Python script, simply import honeybadger and configure it with your API key. Honeybadger uses a global exception hook to automatically report any uncaught exceptions.
diff --git a/honeybadger/contrib/__init__.py b/honeybadger/contrib/__init__.py
index 1379c99..721784b 100644
--- a/honeybadger/contrib/__init__.py
+++ b/honeybadger/contrib/__init__.py
@@ -3,6 +3,7 @@
 from honeybadger.contrib.aws_lambda import AWSLambdaPlugin
 from honeybadger.contrib.logger import HoneybadgerHandler
 from honeybadger.contrib.asgi import ASGIHoneybadger
+from honeybadger.contrib.celery import CeleryHoneybadger
 
 __all__ = [
     'FlaskHoneybadger',
@@ -10,4 +11,5 @@
     'AWSLambdaPlugin',
     'HoneybadgerHandler'
     'ASGIHoneybadger',
+    'CeleryHoneybadger',
 ]
diff --git a/honeybadger/contrib/celery.py b/honeybadger/contrib/celery.py
new file mode 100644
index 0000000..3d6eac1
--- /dev/null
+++ b/honeybadger/contrib/celery.py
@@ -0,0 +1,94 @@
+from celery import current_task
+from celery.signals import task_failure, task_postrun
+from honeybadger import honeybadger
+from honeybadger.plugins import Plugin, default_plugin_manager
+
+
+class CeleryPlugin(Plugin):
+    def __init__(self):
+        super().__init__("Celery")
+
+    def supports(self, config, context):
+        """
+        Check whether this is a a celery task or not.
+        :param config: honeybadger configuration.
+        :param context: current honeybadger configuration.
+        :return: True if this is a celery task, False else.
+        """
+        return current_task != None
+
+    def generate_payload(self, default_payload, config, context):
+        """
+        Generate payload by checking celery task object.
+        :param context: current context.
+        :param config: honeybadger configuration.
+        :return: a dict with the generated payload.
+        """
+        payload = {
+            "component": current_task.__module__,
+            "action": current_task.name,
+            "params": {
+                "args": list(current_task.request.args),
+                "kwargs": current_task.request.kwargs,
+            },
+            "context": {
+                "task_id": current_task.request.id,
+                "retries": current_task.request.retries,
+                "max_retries": current_task.max_retries,
+            },
+        }
+        default_payload["request"].update(payload)
+        return default_payload
+
+
+class CeleryHoneybadger(object):
+    def __init__(self, app, report_exceptions=False):
+        self.app = app
+        self.report_exceptions = report_exceptions
+        default_plugin_manager.register(CeleryPlugin())
+        if app is not None:
+            self.init_app()
+
+    def init_app(self):
+        """
+        Initialize honeybadger and listen for errors.
+        """
+        self._initialize_honeybadger(self.app.conf)
+        if self.report_exceptions:
+            task_failure.connect(self._on_task_failure, weak=False)
+        task_postrun.connect(self._on_task_postrun, weak=False)
+
+    def _on_task_postrun(self, *args, **kwargs):
+        """
+        Callback executed after a task is finished.
+        """
+        honeybadger.reset_context()
+
+    def _on_task_failure(self, *args, **kwargs):
+        """
+        Report exception to honeybadger when a task fails.
+        """
+        honeybadger.notify(exception=kwargs["exception"])
+
+    def _initialize_honeybadger(self, config):
+        """
+        Initializes honeybadger using the given config object.
+        :param dict config: a dict or dict-like object that contains honeybadger configuration properties.
+        """
+        api_key = config.get("HONEYBADGER_API_KEY")
+        if not api_key:
+            return
+        honeybadger_config = {
+            "api_key": api_key,
+            "environment": config.get("HONEYBADGER_ENVIRONMENT", "development"),
+        }
+        honeybadger.configure(**honeybadger_config)
+        honeybadger.config.set_12factor_config()  # environment should override celery settings
+
+    def tearDowm(self):
+        """
+        Disconnects celery signals.
+        """
+        task_postrun.disconnect(self._on_task_postrun)
+        if self.report_exceptions:
+            task_failure.disconnect(self._on_task_failure)
diff --git a/setup.py b/setup.py
index df6cc2e..6faae60 100644
--- a/setup.py
+++ b/setup.py
@@ -4,7 +4,7 @@
 from codecs import open
 from setuptools import setup
 
-tests_require = ['nose', 'mock', 'testfixtures', 'blinker', 'async-asgi-testclient', 'aiounittest', 'fastapi', 'httpx']
+tests_require = ['nose', 'mock', 'testfixtures', 'blinker', 'async-asgi-testclient', 'aiounittest', 'fastapi', 'httpx', 'celery', 'importlib-metadata==4.8.3']
 
 if sys.version_info[0:2] >= (3, 5):
     tests_require.append('Flask>=1.0')


Test patch:
diff --git a/honeybadger/tests/contrib/test_celery.py b/honeybadger/tests/contrib/test_celery.py
new file mode 100644
index 0000000..3eb72a5
--- /dev/null
+++ b/honeybadger/tests/contrib/test_celery.py
@@ -0,0 +1,109 @@
+import unittest
+from unittest.mock import patch
+from celery import Celery
+from honeybadger.contrib.celery import CeleryHoneybadger
+
+
+class CeleryluginTestCase(unittest.TestCase):
+    def setUp(self):
+        super().setUp()
+        self.app = Celery(__name__)
+        self.app.conf.update(
+            CELERY_ALWAYS_EAGER=True,
+            HONEYBADGER_API_KEY="test",
+            HONEYBADGER_ENVIRONMENT="celery_test",
+        )
+        self.celery_hb = None
+
+    def get_mock_args(self, mock):
+        return mock.call_args[0][1]
+
+    def tearDown(self):
+        super().tearDown()
+        self.celery_hb.tearDowm()
+
+    @patch("honeybadger.connection.send_notice")
+    def test_celery_task_with_exception(self, mock):
+        self.celery_hb = CeleryHoneybadger(self.app, report_exceptions=True)
+
+        @self.app.task
+        def error():
+            return 1 / 0
+
+        error.delay()
+        mock.assert_called_once()
+        self.assertEqual(
+            self.get_mock_args(mock)["error"]["class"], "ZeroDivisionError"
+        )
+        self.assertEqual(
+            self.get_mock_args(mock)["error"]["message"], "division by zero"
+        )
+
+    @patch("honeybadger.connection.send_notice")
+    def test_celery_task_with_params(self, mock):
+        self.celery_hb = CeleryHoneybadger(self.app, report_exceptions=True)
+
+        @self.app.task
+        def error(a, b, c):
+            return a / b
+
+        error.delay(1, 0, c=3)
+        mock.assert_called_once()
+        self.assertEqual(self.get_mock_args(mock)["request"]["params"]["args"], [1, 0])
+        self.assertEqual(
+            self.get_mock_args(mock)["request"]["params"]["kwargs"], {"c": 3}
+        )
+
+    @patch("honeybadger.connection.send_notice")
+    def test_celery_task_without_retries(self, mock):
+        self.celery_hb = CeleryHoneybadger(self.app, report_exceptions=True)
+
+        @self.app.task
+        def error():
+            return 1 / 0
+
+        error.delay()
+        mock.assert_called_once()
+        self.assertEqual(self.get_mock_args(mock)["request"]["context"]["retries"], 0)
+        self.assertEqual(
+            self.get_mock_args(mock)["request"]["context"]["max_retries"], 3
+        )
+
+    @patch("honeybadger.connection.send_notice")
+    def test_celery_task_with_retries(self, mock):
+        self.celery_hb = CeleryHoneybadger(self.app, report_exceptions=True)
+
+        @self.app.task(bind=True, max_retries=5, autoretry_for=(ZeroDivisionError,))
+        def error(self):
+            return 1 / 0
+
+        error.delay()
+        mock.assert_called_once()
+        self.assertEqual(self.get_mock_args(mock)["request"]["context"]["retries"], 5)
+        self.assertEqual(
+            self.get_mock_args(mock)["request"]["context"]["max_retries"], 5
+        )
+
+    @patch("honeybadger.connection.send_notice")
+    @patch("honeybadger.honeybadger.reset_context")
+    def test_celery_task_with_reset_context(self, mock_reset, mock_send):
+        self.celery_hb = CeleryHoneybadger(self.app, report_exceptions=True)
+
+        @self.app.task
+        def error():
+            return 1 / 0
+
+        error.delay()
+        mock_send.assert_called_once()
+        mock_reset.assert_called_once()
+
+    @patch("honeybadger.connection.send_notice")
+    def test_without_auto_report_exceptions(self, mock):
+        self.celery_hb = CeleryHoneybadger(self.app, report_exceptions=False)
+
+        @self.app.task
+        def error():
+            return 1 / 0
+
+        error.delay()
+        mock.assert_not_called()