Repository: scoutapp/scout_apm_python

Issue:
Celery exceptions cause error in capture_stacktrace
The latest version of celery, 5.2.1, raises some exceptions in an unexpected way.

For this particular case, `tb` was a string of value:

```
'Traceback (most recent call last):\n  File "/app/.heroku/python/lib/python3.9/site-packages/billiard/pool.py", line 366, in workloop\n    put((READY, (job, i, result, inqW_fd)))\n  File "/app/.heroku/python/lib/python3.9/site-packages/billiard/queues.py", line 366, in put\n    self.send_payload(ForkingPickler.dumps(obj))\n  File "/app/.heroku/python/lib/python3.9/site-packages/billiard/reduction.py", line 56, in dumps\n    cls(buf, protocol).dump(obj)\nbilliard.pool.MaybeEncodingError: Error sending res...'
```

Stacktrace of error
```
AttributeError: 'str' object has no attribute 'tb_frame'
  File "celery/utils/dispatch/signal.py", line 276, in send
    response = receiver(signal=self, sender=sender, **named)
  File "scout_apm/celery.py", line 114, in task_failure_callback
    ErrorMonitor.send(
  File "scout_apm/core/error.py", line 69, in send
    for frame in capture_stacktrace(traceback)
  File "scout_apm/core/backtrace.py", line 132, in capture_stacktrace
    return list(reversed(list(itertools.islice(walker, LIMIT))))
  File "scout_apm/core/backtrace.py", line 75, in stacktrace_walker
    for frame, lineno in traceback.walk_tb(tb):
  File "traceback.py", line 312, in walk_tb
    yield tb.tb_frame, tb.tb_lineno
```


Discussion:

Resolving patch:
diff --git a/src/scout_apm/celery.py b/src/scout_apm/celery.py
--- a/src/scout_apm/celery.py
+++ b/src/scout_apm/celery.py
@@ -23,7 +23,7 @@ def get_safe_settings():
     get_safe_settings = None
 
 import scout_apm.core
-from scout_apm.compat import datetime_to_timestamp
+from scout_apm.compat import datetime_to_timestamp, string_type
 from scout_apm.core.config import scout_config
 from scout_apm.core.error import ErrorMonitor
 from scout_apm.core.tracked_request import TrackedRequest
@@ -79,6 +79,7 @@ def task_failure_callback(
     args=None,
     kwargs=None,
     traceback=None,
+    einfo=None,
     **remaining
 ):
     tracked_request = TrackedRequest.instance()
@@ -110,6 +111,12 @@ def task_failure_callback(
             )
             pass
 
+    # Celery occassionally will send the traceback as a string rather
+    # than a Stack trace object as the docs indicate. In that case,
+    # fall back to the billiard ExceptionInfo instance
+    traceback = (
+        traceback if traceback and not isinstance(traceback, string_type) else einfo.tb
+    )
     exc_info = (exception.__class__, exception, traceback)
     ErrorMonitor.send(
         exc_info,


Test patch:
diff --git a/tests/integration/test_celery.py b/tests/integration/test_celery.py
--- a/tests/integration/test_celery.py
+++ b/tests/integration/test_celery.py
@@ -199,6 +199,40 @@ def test_error_task_error_monitor_exception(
     assert actual_debugs[0].startswith(log_starts_with)
 
 
+@mock.patch("scout_apm.celery.get_safe_settings", False)
+@mock.patch("scout_apm.celery.ErrorMonitor.send")
+def test_celery_task_failure_callback_tracback_fallback(
+    mock_send, mock_get_safe_settings, tracked_request
+):
+    """
+    Verify that when the traceback param is a string, it uses einfo.tb
+    """
+    exception = Exception()
+    mock_sender = mock.Mock()
+    mock_sender.name = "test"
+    scout_apm.celery.task_failure_callback(
+        sender=mock_sender,
+        task_id=None,
+        exception=exception,
+        args=None,
+        kwargs=None,
+        traceback="spam",
+        einfo=mock.Mock(tb="traceback"),
+    )
+    mock_send.assert_called_once_with(
+        (Exception, exception, "traceback"),
+        environment=None,
+        custom_params={
+            "celery": {
+                "task_id": None,
+                "args": None,
+                "kwargs": None,
+            }
+        },
+        custom_controller="test",
+    )
+
+
 @skip_unless_celery_4_plus
 def test_hello_worker(celery_app, celery_worker, tracked_requests):
     with app_with_scout(app=celery_app) as app: