diff --git a/features/celery.feature b/features/celery.feature new file mode 100644 index 00000000..969b90d3 --- /dev/null +++ b/features/celery.feature @@ -0,0 +1,112 @@ +Feature: Celery + +Scenario Outline: Handled exceptions are delivered in Celery + Given I start the service "celery-" + When I execute the command "python bugsnag_celery_test_app/queue_task.py handled" in the service "celery-" + And I wait to receive an error + Then the error is valid for the error reporting API version "4.0" for the "Python Bugsnag Notifier" notifier + And the exception "errorClass" equals "Exception" + And the exception "message" equals "oooh nooo" + And the event "unhandled" is false + And the event "severity" equals "warning" + And the event "severityReason.type" equals "handledException" + And the event "device.runtimeVersions.celery" matches "\.\d+\.\d+" + + Examples: + | celery-version | + | 4 | + | 5 | + +Scenario Outline: Unhandled exceptions are delivered in Celery + Given I start the service "celery-" + When I execute the command "python bugsnag_celery_test_app/queue_task.py unhandled" in the service "celery-" + And I wait to receive an error + Then the error is valid for the error reporting API version "4.0" for the "Python Bugsnag Notifier" notifier + And the exception "errorClass" equals "KeyError" + And the exception "message" equals "'b'" + And the event "unhandled" is true + And the event "severity" equals "error" + And the event "severityReason.type" equals "unhandledExceptionMiddleware" + And the event "severityReason.attributes.framework" equals "Celery" + And the event "device.runtimeVersions.celery" matches "\.\d+\.\d+" + And the event "context" equals "bugsnag_celery_test_app.tasks.unhandled" + And the event "metaData.extra_data.task_id" is not null + # these aren't strings but the maze runner step works on arrays and hashes + And the event "metaData.extra_data.args" string is empty + And the event "metaData.extra_data.kwargs" string is empty + + Examples: + | celery-version | + | 4 | + | 5 | + +Scenario Outline: Task arguments are added to metadata in Celery + Given I start the service "celery-" + When I execute the command "python bugsnag_celery_test_app/queue_task.py add 1 2 3 '4' a=100 b=200" in the service "celery-" + And I wait to receive an error + Then the error is valid for the error reporting API version "4.0" for the "Python Bugsnag Notifier" notifier + And the exception "errorClass" equals "AssertionError" + And the exception "message" equals "" + And the event "unhandled" is true + And the event "severity" equals "error" + And the event "severityReason.type" equals "unhandledExceptionMiddleware" + And the event "severityReason.attributes.framework" equals "Celery" + And the event "device.runtimeVersions.celery" matches "\.\d+\.\d+" + And the event "context" equals "bugsnag_celery_test_app.tasks.add" + And the event "metaData.extra_data.task_id" is not null + And the error payload field "events.0.metaData.extra_data.args" is an array with 4 elements + And the event "metaData.extra_data.args.0" equals "1" + And the event "metaData.extra_data.args.1" equals "2" + And the event "metaData.extra_data.args.2" equals "3" + And the event "metaData.extra_data.args.3" equals "4" + And the event "metaData.extra_data.kwargs.a" equals "100" + And the event "metaData.extra_data.kwargs.b" equals "200" + + Examples: + | celery-version | + | 4 | + | 5 | + +Scenario Outline: Errors in shared tasks are reported in Celery + Given I start the service "celery-" + When I execute the command "python bugsnag_celery_test_app/queue_task.py divide 10 0" in the service "celery-" + And I wait to receive an error + Then the error is valid for the error reporting API version "4.0" for the "Python Bugsnag Notifier" notifier + And the exception "errorClass" equals "ZeroDivisionError" + And the exception "message" equals "division by zero" + And the event "unhandled" is true + And the event "severity" equals "error" + And the event "severityReason.type" equals "unhandledExceptionMiddleware" + And the event "severityReason.attributes.framework" equals "Celery" + And the event "device.runtimeVersions.celery" matches "\.\d+\.\d+" + And the event "context" equals "bugsnag_celery_test_app.tasks.divide" + And the event "metaData.extra_data.task_id" is not null + And the error payload field "events.0.metaData.extra_data.args" is an array with 2 elements + And the event "metaData.extra_data.args.0" equals "10" + And the event "metaData.extra_data.args.1" equals "0" + And the event "metaData.extra_data.kwargs" string is empty + + Examples: + | celery-version | + | 4 | + | 5 | + +Scenario Outline: Successful tasks do not report errors in Celery + Given I start the service "celery-" + When I execute the command "python bugsnag_celery_test_app/queue_task.py add 1 2 3 4 5 6 7 a=8 b=9" in the service "celery-" + Then I should receive no errors + + Examples: + | celery-version | + | 4 | + | 5 | + +Scenario Outline: Successful shared tasks do not report errors in Celery + Given I start the service "celery-" + When I execute the command "python bugsnag_celery_test_app/queue_task.py divide 10 2" in the service "celery-" + Then I should receive no errors + + Examples: + | celery-version | + | 4 | + | 5 | diff --git a/features/fixtures/celery/Dockerfile b/features/fixtures/celery/Dockerfile new file mode 100644 index 00000000..fddff847 --- /dev/null +++ b/features/fixtures/celery/Dockerfile @@ -0,0 +1,12 @@ +ARG PYTHON_TEST_VERSION +FROM python:$PYTHON_TEST_VERSION + +COPY app/ /usr/src/app +COPY temp-bugsnag-python/ /usr/src/bugsnag + +WORKDIR /usr/src/app + +ARG CELERY_TEST_VERSION +RUN CELERY_TEST_VERSION=$CELERY_TEST_VERSION pip install --no-cache-dir -r requirements.txt + +CMD celery --app bugsnag_celery_test_app.main worker -l INFO diff --git a/features/fixtures/celery/app/bugsnag_celery_test_app/__init__.py b/features/fixtures/celery/app/bugsnag_celery_test_app/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/features/fixtures/celery/app/bugsnag_celery_test_app/main.py b/features/fixtures/celery/app/bugsnag_celery_test_app/main.py new file mode 100644 index 00000000..a56efc9b --- /dev/null +++ b/features/fixtures/celery/app/bugsnag_celery_test_app/main.py @@ -0,0 +1,24 @@ +import os +import bugsnag +from celery import Celery +from bugsnag.celery import connect_failure_handler + + +bugsnag.configure( + api_key=os.environ["BUGSNAG_API_KEY"], + endpoint=os.environ["BUGSNAG_ERROR_ENDPOINT"], + session_endpoint=os.environ["BUGSNAG_SESSION_ENDPOINT"], +) + +app = Celery( + 'bugsnag_celery_test_app', + broker='redis://redis:6379', + backend='rpc://', + include=['bugsnag_celery_test_app.tasks'], +) + +connect_failure_handler() + + +if __name__ == '__main__': + app.start() diff --git a/features/fixtures/celery/app/bugsnag_celery_test_app/queue_task.py b/features/fixtures/celery/app/bugsnag_celery_test_app/queue_task.py new file mode 100644 index 00000000..f038fbf2 --- /dev/null +++ b/features/fixtures/celery/app/bugsnag_celery_test_app/queue_task.py @@ -0,0 +1,23 @@ +import sys +import json +import bugsnag_celery_test_app.tasks as tasks + + +if __name__ == '__main__': + task = sys.argv[1] + arguments = [] + keyword_arguments = {} + + if len(sys.argv) > 2: + raw_arguments = sys.argv[2:] + + for argument in raw_arguments: + if '=' in argument: + key, value = argument.split('=') + keyword_arguments[key] = value + else: + arguments.append(argument) + + print("~*~ Queueing task '%s' with args: [%s] and kwargs: %s" % (task, ", ".join(arguments), json.dumps(keyword_arguments))) + + getattr(tasks, task).delay(*arguments, **keyword_arguments) diff --git a/features/fixtures/celery/app/bugsnag_celery_test_app/tasks.py b/features/fixtures/celery/app/bugsnag_celery_test_app/tasks.py new file mode 100644 index 00000000..b6f94c28 --- /dev/null +++ b/features/fixtures/celery/app/bugsnag_celery_test_app/tasks.py @@ -0,0 +1,34 @@ +import bugsnag +from celery import shared_task +from bugsnag_celery_test_app.main import app + + +@app.task +def handled(): + bugsnag.notify(Exception('oooh nooo')) + + return 'hello world' + + +@app.task +def unhandled(): + a = {} + + return a['b'] + + +@app.task +def add(*args, a, b): + total = int(a) + int(b) + + for arg in args: + total += int(arg) + + assert total < 100 + + return total + + +@shared_task +def divide(a, b): + return int(a) / int(b) diff --git a/features/fixtures/celery/app/requirements.txt b/features/fixtures/celery/app/requirements.txt new file mode 100644 index 00000000..ce07aeb3 --- /dev/null +++ b/features/fixtures/celery/app/requirements.txt @@ -0,0 +1,4 @@ +. +../bugsnag +celery[redis]${CELERY_TEST_VERSION} +importlib-metadata<5.0 diff --git a/features/fixtures/celery/app/setup.py b/features/fixtures/celery/app/setup.py new file mode 100644 index 00000000..a0bdc947 --- /dev/null +++ b/features/fixtures/celery/app/setup.py @@ -0,0 +1,3 @@ +from setuptools import setup + +setup(name="bugsnag_celery_test_app", packages=["bugsnag_celery_test_app"]) diff --git a/features/fixtures/docker-compose.yml b/features/fixtures/docker-compose.yml index c8d4ae6b..afd7becb 100644 --- a/features/fixtures/docker-compose.yml +++ b/features/fixtures/docker-compose.yml @@ -1,6 +1,9 @@ version: "3.8" services: + redis: + image: redis + plain: build: context: plain @@ -27,3 +30,26 @@ services: volumes: - "/var/run/docker.sock:/var/run/docker.sock" - "./aws-lambda/app:/usr/src/app" + + celery-4: &celery + build: + context: celery + args: + - PYTHON_TEST_VERSION + - CELERY_TEST_VERSION=>=4,<5 + environment: + - BUGSNAG_API_KEY + - BUGSNAG_ERROR_ENDPOINT + - BUGSNAG_SESSION_ENDPOINT + extra_hosts: + - "host.docker.internal:host-gateway" + depends_on: + - redis + + celery-5: + <<: *celery + build: + context: celery + args: + - PYTHON_TEST_VERSION + - CELERY_TEST_VERSION=>=5,<6 diff --git a/features/support/env.rb b/features/support/env.rb index 9f3ae675..6c7e3a23 100644 --- a/features/support/env.rb +++ b/features/support/env.rb @@ -58,3 +58,11 @@ def current_ip skip_this_scenario if PYTHON_TEST_VERSION == "3.#{minor_version}" end end + +Before do |test_case| + # Celery 5 does not support Python 3.5 + next unless PYTHON_TEST_VERSION == "3.5" + next unless test_case.name.end_with?("Celery 5") + + skip_this_scenario +end diff --git a/tests/integrations/test_celery.py b/tests/integrations/test_celery.py deleted file mode 100644 index 495891d6..00000000 --- a/tests/integrations/test_celery.py +++ /dev/null @@ -1,224 +0,0 @@ -from contextlib import contextmanager -import pytest -import celery -from celery import shared_task -from celery.signals import task_failure -from bugsnag.celery import connect_failure_handler, failure_handler -from tests.utils import MissingRequestError - - -@pytest.fixture(scope='function') -def celery_config(): - return { - 'broker_url': 'memory://', - 'result_backend': 'rpc', - } - - -@contextmanager -def celery_failure_handler(): - """ - The bugsnag celery integration works by listening to the celery - task_failure signal and sending an event when the signal is received. - - This context manages the signal connection and ensures that error handling - does not occur across separate tests. - """ - connect_failure_handler() - try: - yield - finally: - task_failure.disconnect(failure_handler) - - -def test_app_task_operation(celery_app, celery_worker, bugsnag_server): - """ - Configuring bugsnag should not interfere with tasks succeeding normally - """ - - @celery_app.task - def square(x): - return x * x - - celery_worker.reload() - - with celery_failure_handler(): - assert square.delay(3).get(timeout=1) == 9 - - with pytest.raises(MissingRequestError): - bugsnag_server.wait_for_event() - - assert len(bugsnag_server.events_received) == 0 - - -def test_app_task_failure(celery_app, celery_worker, bugsnag_server): - """ - Bugsnag should capture failures in app tasks - """ - - def validate(x, y): - raise FloatingPointError('expect the unexpected!') - - @celery_app.task - def cube(x): - return x * x * x - - @celery_app.task - def divide(x, y): - if validate(x, y): - return x / y - - celery_worker.reload() - - with celery_failure_handler(): - # bugsnag should not depend on the result being resolved using get() - divide.delay(7, 0) - # other (non-failing) tasks should behave normally - result = cube.delay(3) - bugsnag_server.wait_for_event() - - assert len(bugsnag_server.events_received) == 1 - assert result.get(timeout=1) == 27 - - payload = bugsnag_server.events_received[0]['json_body'] - event = payload['events'][0] - exception = event['exceptions'][0] - task = event['metaData']['extra_data'] - - assert 'task_id' in task - assert task['args'] == [7, 0] - assert event['context'] == 'test_celery.divide' - assert event['severityReason']['type'] == 'unhandledExceptionMiddleware' - assert event['severityReason']['attributes'] == {'framework': 'Celery'} - assert event['device']['runtimeVersions']['celery'] == celery.__version__ - assert exception['errorClass'] == 'FloatingPointError' - assert exception['message'] == 'expect the unexpected!' - assert exception['stacktrace'][0]['method'] == 'validate' - assert exception['stacktrace'][1]['method'] == 'divide' - - -def test_app_task_failure_result_status(celery_app, celery_worker, - bugsnag_server): - """ - Bugsnag integration should not suppress normal failure behavior when - checking the result of a failed task - """ - def validate(x, y): - raise FloatingPointError('expect the unexpected!') - - @celery_app.task - def cube(x): - return x * x * x - - @celery_app.task - def divide(x, y): - if validate(x, y): - return x / y - - celery_worker.reload() - - with celery_failure_handler(): - failed_result = divide.delay(7, 0) - result = cube.delay(3) - - with pytest.raises(FloatingPointError): - # bugsnag should not suppress the exception - failed_result.get(timeout=1) - - bugsnag_server.wait_for_event() - assert len(bugsnag_server.events_received) == 1 - assert result.get(timeout=1) == 27 - - -def test_shared_task_operation(celery_worker, bugsnag_server): - """ - Configuring bugsnag should not interfere with shared tasks succeeding - normally - """ - - @shared_task - def add(x, y): - return x + y - - celery_worker.reload() - - with celery_failure_handler(): - result = add.delay(2, 2) - - with pytest.raises(MissingRequestError): - bugsnag_server.wait_for_event() - - assert len(bugsnag_server.events_received) == 0 - assert result.get(timeout=1) == 4 - - -def test_shared_task_failure(celery_worker, bugsnag_server): - """ - Bugsnag should capture failures in standalone tasks - """ - - @shared_task - def divide(x, y, **kwargs): - return x / y - - celery_worker.reload() - - with celery_failure_handler(): - divide.delay(2, 0, parts='multi', cache=2) - - bugsnag_server.wait_for_event() - - assert len(bugsnag_server.events_received) == 1 - - payload = bugsnag_server.events_received[0]['json_body'] - event = payload['events'][0] - exception = event['exceptions'][0] - task = event['metaData']['extra_data'] - - assert 'task_id' in task - assert task['args'] == [2, 0] - assert task['kwargs'] == {'parts': 'multi', 'cache': 2} - assert event['context'] == 'test_celery.divide' - assert event['severityReason']['type'] == 'unhandledExceptionMiddleware' - assert event['severityReason']['attributes'] == {'framework': 'Celery'} - assert event['device']['runtimeVersions']['celery'] == celery.__version__ - assert exception['errorClass'] == 'ZeroDivisionError' - assert exception['stacktrace'][0]['method'] == 'divide' - - -def test_task_failure_with_chained_exceptions( - celery_app, - celery_worker, - bugsnag_server -): - @celery_app.task - def oh_no(): - try: - try: - raise Exception('A') - except Exception as exception: - raise RuntimeError('B') from exception - except RuntimeError: - raise ArithmeticError('C') - - celery_worker.reload() - - with celery_failure_handler(): - oh_no.delay() - bugsnag_server.wait_for_event() - - assert len(bugsnag_server.events_received) == 1 - - payload = bugsnag_server.events_received[0]['json_body'] - event = payload['events'][0] - - assert len(event['exceptions']) == 3 - - assert event['exceptions'][0]['errorClass'] == 'ArithmeticError' - assert event['exceptions'][0]['message'] == 'C' - - assert event['exceptions'][1]['errorClass'] == 'RuntimeError' - assert event['exceptions'][1]['message'] == 'B' - - assert event['exceptions'][2]['errorClass'] == 'Exception' - assert event['exceptions'][2]['message'] == 'A' diff --git a/tox.ini b/tox.ini index 61ab3eac..b887d601 100644 --- a/tox.ini +++ b/tox.ini @@ -1,8 +1,7 @@ [tox] envlist= py{35,36,37,38,39,310,311,312}-{test,requests,flask,tornado,wsgi,bottle} - py{35,36,37,38,39,310}-celery4 - py{36,37,38,39,310,311,312}-{asgi,celery5} + py{36,37,38,39,310,311,312}-asgi py{35,36,37}-django{18,19,110,111} py{35,36,37,38,39}-django20 py{35,36,37,38,39,310}-django{21,22} @@ -46,10 +45,6 @@ deps= asgi: httpx bottle: webtest bottle: bottle - celery4: celery>=4,<5 - celery5: celery>=5,<6 - celery5: pytest-celery<1 - py37-celery{4,5}: importlib_metadata<5 flask: flask flask: blinker tornado: tornado @@ -80,7 +75,6 @@ commands = threadtest: pytest -p no:threadexception tests/integrations/test_thread_excepthook.py requests: pytest --ignore=tests/integrations tests tests/integrations/test_requests_delivery.py bottle: pytest tests/integrations/test_bottle.py - celery{4,5}: pytest tests/integrations/test_celery.py wsgi: pytest tests/integrations/test_wsgi.py asgi: pytest tests/integrations/test_asgi.py flask: pytest tests/integrations/test_flask.py