diff --git a/.github/workflows/license-audit.yml b/.github/workflows/license-audit.yml index f40aa1c9..5f0998ea 100644 --- a/.github/workflows/license-audit.yml +++ b/.github/workflows/license-audit.yml @@ -33,5 +33,5 @@ jobs: docker run -v $PWD:/scan licensefinder/license_finder /bin/bash -lc " cd /scan && pip3 install -r requirements.txt --quiet && - license_finder --decisions-file decisions.yml --python-version 3 + license_finder --decisions-file decisions.yml --python-version 3 --enabled-package-managers=pip " diff --git a/.github/workflows/maze-runner.yml b/.github/workflows/maze-runner.yml new file mode 100644 index 00000000..9d89beb0 --- /dev/null +++ b/.github/workflows/maze-runner.yml @@ -0,0 +1,29 @@ +name: Maze Runner + +on: [push, pull_request] + +jobs: + maze-runner: + runs-on: 'ubuntu-latest' + + strategy: + fail-fast: false + matrix: + python-version: ['3.5', '3.6', '3.7', '3.8', '3.9', '3.10', '3.11', '3.12'] + + steps: + - uses: actions/checkout@v4 + + - name: Install libcurl4-openssl-dev and net-tools + run: | + sudo apt-get update + sudo apt-get install libcurl4-openssl-dev net-tools + + - uses: ruby/setup-ruby@v1 + with: + ruby-version: '3.3' + bundler-cache: true + + - run: bundle exec maze-runner --no-source + env: + PYTHON_TEST_VERSION: ${{ matrix.python-version }} diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index 6e3c980d..6525b40a 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -9,7 +9,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ['3.7', '3.8', '3.9', '3.10', '3.11'] + python-version: ['3.7', '3.8', '3.9', '3.10', '3.11', '3.12'] os: ['ubuntu-latest'] include: - python-version: '3.5' @@ -27,7 +27,7 @@ jobs: - name: Install dependencies run: | - python -m pip install coveralls 'tox<4.0.0' tox-factor + python -m pip install coveralls 'tox<4.0.0' tox-factor setuptools - name: Run tests run: | diff --git a/.gitignore b/.gitignore index daf3a39f..93f38ee8 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,7 @@ +.aws-sam +maze-runner.log +maze_output + *.py[co] # Packages diff --git a/CHANGELOG.md b/CHANGELOG.md index 576ac3da..382946bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,12 @@ Changelog ========= +## v4.7.0 (2024-04-24) + +### Enhancements + +* Add support for AWS Lambda functions via the new `bugsnag.aws_lambda_handler` decorator. See [the documentation for usage instructions](https://docs.bugsnag.com/platforms/python/aws-lambda/). + ## v4.6.2 (2024-03-05) ### Bug fixes diff --git a/Gemfile b/Gemfile new file mode 100644 index 00000000..fec362a2 --- /dev/null +++ b/Gemfile @@ -0,0 +1,3 @@ +source "https://rubygems.org" + +gem 'bugsnag-maze-runner', '~> 9.6' diff --git a/Gemfile.lock b/Gemfile.lock new file mode 100644 index 00000000..28bb456c --- /dev/null +++ b/Gemfile.lock @@ -0,0 +1,140 @@ +GEM + remote: https://rubygems.org/ + specs: + appium_lib (12.0.1) + appium_lib_core (~> 5.0) + nokogiri (~> 1.8, >= 1.8.1) + tomlrb (>= 1.1, < 3.0) + appium_lib_core (5.4.0) + faye-websocket (~> 0.11.0) + selenium-webdriver (~> 4.2, < 4.6) + bugsnag (6.26.4) + concurrent-ruby (~> 1.0) + bugsnag-maze-runner (9.6.0) + appium_lib (~> 12.0.0) + appium_lib_core (~> 5.4.0) + bugsnag (~> 6.24) + cucumber (~> 7.1) + cucumber-expressions (~> 6.0.0) + curb (~> 0.9.6) + dogstatsd-ruby (~> 5.5.0) + json_schemer (~> 0.2.24) + optimist (~> 3.0.1) + os (~> 1.0.0) + rack (~> 2.2) + rake (~> 12.3.3) + rubyzip (~> 2.3.2) + selenium-webdriver (~> 4.0) + test-unit (~> 3.5.2) + webrick (~> 1.7.0) + builder (3.2.4) + childprocess (4.1.0) + concurrent-ruby (1.2.3) + cucumber (7.1.0) + builder (~> 3.2, >= 3.2.4) + cucumber-core (~> 10.1, >= 10.1.0) + cucumber-create-meta (~> 6.0, >= 6.0.1) + cucumber-cucumber-expressions (~> 14.0, >= 14.0.0) + cucumber-gherkin (~> 22.0, >= 22.0.0) + cucumber-html-formatter (~> 17.0, >= 17.0.0) + cucumber-messages (~> 17.1, >= 17.1.1) + cucumber-wire (~> 6.2, >= 6.2.0) + diff-lcs (~> 1.4, >= 1.4.4) + mime-types (~> 3.3, >= 3.3.1) + multi_test (~> 0.1, >= 0.1.2) + sys-uname (~> 1.2, >= 1.2.2) + cucumber-core (10.1.1) + cucumber-gherkin (~> 22.0, >= 22.0.0) + cucumber-messages (~> 17.1, >= 17.1.1) + cucumber-tag-expressions (~> 4.1, >= 4.1.0) + cucumber-create-meta (6.0.4) + cucumber-messages (~> 17.1, >= 17.1.1) + sys-uname (~> 1.2, >= 1.2.2) + cucumber-cucumber-expressions (14.0.0) + cucumber-expressions (6.0.1) + cucumber-gherkin (22.0.0) + cucumber-messages (~> 17.1, >= 17.1.1) + cucumber-html-formatter (17.0.0) + cucumber-messages (~> 17.1, >= 17.1.0) + cucumber-messages (17.1.1) + cucumber-tag-expressions (4.1.0) + cucumber-wire (6.2.1) + cucumber-core (~> 10.1, >= 10.1.0) + cucumber-cucumber-expressions (~> 14.0, >= 14.0.0) + curb (0.9.11) + diff-lcs (1.5.1) + dogstatsd-ruby (5.5.0) + ecma-re-validator (0.4.0) + regexp_parser (~> 2.2) + eventmachine (1.2.7) + faye-websocket (0.11.3) + eventmachine (>= 0.12.0) + websocket-driver (>= 0.5.1) + ffi (1.16.3) + hana (1.3.7) + json_schemer (0.2.25) + ecma-re-validator (~> 0.3) + hana (~> 1.3) + regexp_parser (~> 2.0) + simpleidn (~> 0.2) + uri_template (~> 0.7) + mime-types (3.5.2) + mime-types-data (~> 3.2015) + mime-types-data (3.2024.0305) + multi_test (0.1.2) + nokogiri (1.16.3-aarch64-linux) + racc (~> 1.4) + nokogiri (1.16.3-arm-linux) + racc (~> 1.4) + nokogiri (1.16.3-arm64-darwin) + racc (~> 1.4) + nokogiri (1.16.3-x86-linux) + racc (~> 1.4) + nokogiri (1.16.3-x86_64-darwin) + racc (~> 1.4) + nokogiri (1.16.3-x86_64-linux) + racc (~> 1.4) + optimist (3.0.1) + os (1.0.1) + power_assert (2.0.3) + racc (1.7.3) + rack (2.2.9) + rake (12.3.3) + regexp_parser (2.9.0) + rexml (3.2.6) + rubyzip (2.3.2) + selenium-webdriver (4.5.0) + childprocess (>= 0.5, < 5.0) + rexml (~> 3.2, >= 3.2.5) + rubyzip (>= 1.2.2, < 3.0) + websocket (~> 1.0) + simpleidn (0.2.1) + unf (~> 0.1.4) + sys-uname (1.2.3) + ffi (~> 1.1) + test-unit (3.5.9) + power_assert + tomlrb (2.0.3) + unf (0.1.4) + unf_ext + unf_ext (0.0.9.1) + uri_template (0.7.0) + webrick (1.7.0) + websocket (1.2.10) + websocket-driver (0.7.6) + websocket-extensions (>= 0.1.0) + websocket-extensions (0.1.5) + +PLATFORMS + aarch64-linux + arm-linux + arm64-darwin + x86-linux + x86_64-darwin + x86_64-linux + +DEPENDENCIES + bugsnag-maze-runner (~> 9.6) + +BUNDLED WITH + 2.5.1 diff --git a/README.md b/README.md index de1693e5..8e05f091 100644 --- a/README.md +++ b/README.md @@ -2,10 +2,7 @@ [![Build status](https://img.shields.io/travis/bugsnag/bugsnag-python/master.svg?style=flat-square)](https://travis-ci.com/bugsnag/bugsnag-python) [![Documentation](https://img.shields.io/badge/documentation-latest-blue.svg)](https://docs.bugsnag.com/platforms/python/) -The Bugsnag exception reporter for Python automatically detects and reports -exceptions thrown your **Django**, **WSGI**, **Tornado**, **Flask** or -**plain Python** app. Any uncaught exceptions will trigger a notification to be -sent to your Bugsnag project. Learn more about [monitoring and reporting Python errors](https://www.bugsnag.com/platforms/python-error-reporting/) with Bugsnag. +The Bugsnag exception reporter for Python automatically detects and reports exceptions thrown your **ASGI**, **AWS Lambda**, **Bottle**, **Celery**, **Django**, **Flask**, **Tornado**, **WSGI** or **plain Python** app. Any uncaught exceptions will trigger a notification to be sent to your Bugsnag project. Learn more about [monitoring and reporting Python errors](https://www.bugsnag.com/platforms/python-error-reporting/) with Bugsnag. ## Features diff --git a/bugsnag/__init__.py b/bugsnag/__init__.py index 5a74a852..9c023b2f 100644 --- a/bugsnag/__init__.py +++ b/bugsnag/__init__.py @@ -15,7 +15,8 @@ auto_notify_exc_info, logger, leave_breadcrumb, add_on_breadcrumb, remove_on_breadcrumb, add_feature_flag, add_feature_flags, - clear_feature_flag, clear_feature_flags) + clear_feature_flag, clear_feature_flags, + aws_lambda_handler) __all__ = ('Client', 'Event', 'Configuration', 'RequestConfiguration', 'configuration', 'configure', 'configure_request', @@ -25,4 +26,5 @@ 'BreadcrumbType', 'Breadcrumb', 'Breadcrumbs', 'OnBreadcrumbCallback', 'leave_breadcrumb', 'add_on_breadcrumb', 'remove_on_breadcrumb', 'FeatureFlag', 'add_feature_flag', - 'add_feature_flags', 'clear_feature_flag', 'clear_feature_flags') + 'add_feature_flags', 'clear_feature_flag', 'clear_feature_flags', + 'aws_lambda_handler') diff --git a/bugsnag/client.py b/bugsnag/client.py index 5797fd79..558b074a 100644 --- a/bugsnag/client.py +++ b/bugsnag/client.py @@ -2,9 +2,9 @@ import sys import threading import warnings +import functools from datetime import datetime, timezone -from functools import wraps from typing import Union, Tuple, Callable, Optional, List, Type, Dict, Any from bugsnag.breadcrumbs import ( @@ -19,6 +19,7 @@ from bugsnag.sessiontracker import SessionTracker from bugsnag.utils import to_rfc3339 from bugsnag.context import ContextLocalState +from bugsnag.request_tracker import RequestTracker __all__ = ('Client',) @@ -36,6 +37,7 @@ def __init__(self, configuration: Optional[Configuration] = None, self.session_tracker = SessionTracker(self.configuration) self.configuration.configure(**kwargs) self._context = ContextLocalState(self) + self._request_tracker = RequestTracker() if install_sys_hook: self.install_sys_hook() @@ -174,11 +176,6 @@ def run_middleware(): initial_reason = event.severity_reason.copy() def send_payload(): - if asynchronous is None: - options = {} - else: - options = {'asynchronous': asynchronous} - if event.api_key is None: self.configuration.logger.warning( "No API key configured, couldn't notify" @@ -192,16 +189,30 @@ def send_payload(): } else: event.severity_reason = initial_reason + payload = event._payload() + + post_delivery_callback = self._request_tracker.new_request() + options = {'post_delivery_callback': post_delivery_callback} + + if asynchronous is not None: + options['asynchronous'] = asynchronous + try: - self.configuration.delivery.deliver(self.configuration, - payload, options) + self.configuration.delivery.deliver( + self.configuration, + payload, + options + ) except Exception as e: self.configuration.logger.exception( 'Notifying Bugsnag failed %s', e ) + # ensure this request is not still marked as in-flight + post_delivery_callback() + # Trigger session delivery self.session_tracker.send_sessions() @@ -325,6 +336,168 @@ def _leave_breadcrumb_for_event(self, event: Event) -> None: BreadcrumbType.ERROR ) + def flush(self, timeout_ms: int) -> None: + # trigger session delivery as there may be outstanding sessions that + # haven't been sent yet + self.session_tracker.send_sessions() + + stop_event = threading.Event() + + def block_until_no_requests(): + while ( + self._request_tracker.has_in_flight_requests() or + self.session_tracker._request_tracker.has_in_flight_requests() + ): + # wait 10ms before checking for in-flight requests again + was_stopped = stop_event.wait(0.01) + + # stop checking and exit if the timeout has been exceeded + if was_stopped: + break + + thread = threading.Thread(target=block_until_no_requests) + thread.start() + thread.join(timeout_ms / 1000) + + if thread.is_alive(): + # tell the thread to stop checking for in-flight requests as the + # timeout has been exceeded + stop_event.set() + + raise Exception("flush timed out after %dms" % timeout_ms) + + def add_metadata_tab(self, tab_name: str, data: Dict[str, Any]) -> None: + metadata = RequestConfiguration.get_instance().metadata + + if tab_name not in metadata: + metadata[tab_name] = {} + + metadata[tab_name].update(data) + + def aws_lambda_handler( + self, + real_handler: Optional[Callable] = None, + flush_timeout_ms: int = 2000, + lambda_timeout_notify_ms: int = 1000, + ) -> Callable: + # handle being called with just 'flush_timeout_ms' + if real_handler is None: + return functools.partial( + self.aws_lambda_handler, + flush_timeout_ms=flush_timeout_ms, + lambda_timeout_notify_ms=lambda_timeout_notify_ms, + ) + + # attributes from the aws context that we want to capture as metadata + # the context is an instance of LambdaContext, which isn't iterable and + # so can't be added to metadata as-is + aws_context_attributes = [ + 'function_name', + 'function_version', + 'invoked_function_arn', + 'memory_limit_in_mb', + 'aws_request_id', + 'log_group_name', + 'log_stream_name', + 'identity', + 'client_context', + ] + + @functools.wraps(real_handler) + def wrapped_handler(aws_event, aws_context): + timer = None + aws_context_metadata = { + attribute: + getattr(aws_context, attribute, None) + for attribute in aws_context_attributes + } + + if lambda_timeout_notify_ms > 0: + # reporting possible timeouts is done using a separate thread, + # but we don't want to lose the information from the main + # thread so we store references here to use later + # TODO: we shouldn't have 3 places where per-request data is + # stored - it should all be in 'self._context' + main_request_config = RequestConfiguration.get_instance() + main_request_breadcrumbs = \ + self.configuration._breadcrumbs._breadcrumbs + main_request_feature_flags = \ + self._context.feature_flag_delegate._storage + + def report_timeout_to_bugsnag(): + # copy over the main thread's data to this thread + RequestConfiguration.set_instance(main_request_config) + + self.configuration._breadcrumbs._breadcrumbs.extend( + main_request_breadcrumbs + ) + + self._context.feature_flag_delegate.merge( + main_request_feature_flags.values() + ) + + # generate an empty traceback object so the lambda timeout + # doesn't have a misleading traceback + try: + raise Exception() + except Exception as exception: + empty_traceback = exception.__traceback__ + + lambda_timeout_approaching = LambdaTimeoutApproaching( + aws_context.get_remaining_time_in_millis(), + empty_traceback + ) + + # set the source_func so the user's lambda handler is the + # only item in the traceback + self.notify( + lambda_timeout_approaching, + source_func=real_handler + ) + + remaining_ms = aws_context.get_remaining_time_in_millis() + timer = threading.Timer( + (remaining_ms - lambda_timeout_notify_ms) / 1000, + report_timeout_to_bugsnag + ) + + timer.start() + + try: + self.add_metadata_tab('AWS Lambda Event', aws_event) + self.add_metadata_tab( + 'AWS Lambda Context', + aws_context_metadata + ) + + if self.configuration.auto_capture_sessions: + self.session_tracker.start_session() + + return real_handler(aws_event, aws_context) + except Exception as exception: + if self.configuration.auto_notify: + self.notify( + exception, + unhandled=True, + severity='error', + severity_reason={'type': 'unhandledException'}, + ) + + raise + finally: + # a timer can only be cancelled if it hasn't fired yet + if timer and timer.is_alive(): + timer.cancel() + + try: + self.flush(flush_timeout_ms) + except Exception as exception: + warnings.warn( + 'Delivery may be unsuccessful: ' + str(exception) + ) + + return wrapped_handler + class ClientContext: def __init__(self, client, @@ -337,7 +510,7 @@ def __init__(self, client, self.exception_types = exception_types or (Exception,) def __call__(self, function: Callable): - @wraps(function) + @functools.wraps(function) def decorate(*args, **kwargs): try: return function(*args, **kwargs) @@ -356,3 +529,9 @@ def __exit__(self, *exc_info): self.client.notify_exc_info(*exc_info, **self.options) return False + + +class LambdaTimeoutApproaching(Exception): + def __init__(self, remaining_ms: int, tb): + super().__init__('Lambda will timeout in %dms' % remaining_ms) + self.__traceback__ = tb diff --git a/bugsnag/configuration.py b/bugsnag/configuration.py index 54b732a1..f96752b8 100644 --- a/bugsnag/configuration.py +++ b/bugsnag/configuration.py @@ -603,6 +603,10 @@ def get_instance(cls): return instance + @classmethod + def set_instance(cls, instance: 'RequestConfiguration') -> None: + _request_info.set(instance) # type: ignore + @classmethod def clear(cls): """ diff --git a/bugsnag/delivery.py b/bugsnag/delivery.py index d2a02195..acd2da2b 100644 --- a/bugsnag/delivery.py +++ b/bugsnag/delivery.py @@ -32,6 +32,23 @@ __all__ = ('default_headers', 'Delivery') +def _noop(): + pass + + +def _marshall_post_delivery_callback(post_delivery_callback): + if not callable(post_delivery_callback): + return _noop + + def safe_post_delivery_callback(): + try: + post_delivery_callback() + except Exception: + pass + + return safe_post_delivery_callback + + def create_default_delivery(): if requests is not None: return RequestsDelivery() @@ -81,6 +98,10 @@ def deliver_sessions(self, config, payload: Any, options=None): self.deliver(config, payload, options) def queue_request(self, request: Callable, config, options: Dict): + post_delivery_callback = _marshall_post_delivery_callback( + options.pop('post_delivery_callback', None) + ) + if config.asynchronous and options.pop('asynchronous', True): # if an exception escapes the thread, our threading.excepthook # will catch it and attempt to deliver it @@ -91,10 +112,15 @@ def safe_request(): request() except Exception as e: config.logger.exception('Notifying Bugsnag failed %s', e) + finally: + post_delivery_callback() Thread(target=safe_request).start() else: - request() + try: + request() + finally: + post_delivery_callback() class UrllibDelivery(Delivery): diff --git a/bugsnag/event.py b/bugsnag/event.py index 8a46561b..1ae4f8da 100644 --- a/bugsnag/event.py +++ b/bugsnag/event.py @@ -436,6 +436,7 @@ def _payload(self): return encoder.encode({ "apiKey": self.api_key, "notifier": _NOTIFIER_INFORMATION, + "payloadVersion": self.PAYLOAD_VERSION, "events": [{ "severity": self.severity, "severityReason": self.severity_reason, diff --git a/bugsnag/legacy.py b/bugsnag/legacy.py index df793db4..018e6735 100644 --- a/bugsnag/legacy.py +++ b/bugsnag/legacy.py @@ -1,4 +1,4 @@ -from typing import Dict, Any, Tuple, Type, Optional, Union, List +from typing import Dict, Any, Tuple, Type, Optional, Union, List, Callable import types import sys @@ -38,11 +38,7 @@ def add_metadata_tab(tab_name: str, data: Dict[str, Any]): bugsnag.add_metadata_tab("user", {"id": "1", "name": "Conrad"}) """ - metadata = RequestConfiguration.get_instance().metadata - if tab_name not in metadata: - metadata[tab_name] = {} - - metadata[tab_name].update(data) + default_client.add_metadata_tab(tab_name, data) def clear_request_config(): @@ -180,3 +176,10 @@ def clear_feature_flag(name: Union[str, bytes]) -> None: def clear_feature_flags() -> None: default_client.clear_feature_flags() + + +def aws_lambda_handler( + real_handler: Optional[Callable] = None, + flush_timeout_ms: int = 2000, +) -> Callable: + return default_client.aws_lambda_handler(real_handler, flush_timeout_ms) diff --git a/bugsnag/notifier.py b/bugsnag/notifier.py index 2a6ac17e..26284415 100644 --- a/bugsnag/notifier.py +++ b/bugsnag/notifier.py @@ -1,5 +1,5 @@ _NOTIFIER_INFORMATION = { 'name': 'Python Bugsnag Notifier', 'url': 'https://github.com/bugsnag/bugsnag-python', - 'version': '4.6.2' + 'version': '4.7.0' } diff --git a/bugsnag/request_tracker.py b/bugsnag/request_tracker.py new file mode 100644 index 00000000..bf735235 --- /dev/null +++ b/bugsnag/request_tracker.py @@ -0,0 +1,49 @@ +from uuid import uuid4 +from threading import Lock +from typing import Callable + + +class RequestTracker: + def __init__(self): + self._mutex = Lock() + self._requests = set() # type: set[str] + + def new_request(self) -> Callable[[], None]: + """ + Track a new request, returning a callback that marks the request as + complete. + + >>> request_tracker = RequestTracker() + >>> mark_request_complete = request_tracker.new_request() + >>> # ...make the request... + >>> mark_request_complete() + """ + request_id = uuid4().hex + + with self._mutex: + self._requests.add(request_id) + + def mark_request_complete(): + with self._mutex: + # we use 'discard' instead of 'remove' to allow this callback + # to be called multiple times without raising an error + self._requests.discard(request_id) + + return mark_request_complete + + def has_in_flight_requests(self) -> bool: + """ + See if there are any requests that have not been marked as completed. + + >>> request_tracker = RequestTracker() + >>> request_tracker.has_in_flight_requests() + False + >>> mark_request_complete = request_tracker.new_request() + >>> request_tracker.has_in_flight_requests() + True + >>> mark_request_complete() + >>> request_tracker.has_in_flight_requests() + False + """ + with self._mutex: + return bool(self._requests) diff --git a/bugsnag/sessiontracker.py b/bugsnag/sessiontracker.py index ecf4dbcd..edb8bf26 100644 --- a/bugsnag/sessiontracker.py +++ b/bugsnag/sessiontracker.py @@ -16,6 +16,7 @@ from bugsnag.notifier import _NOTIFIER_INFORMATION from bugsnag.utils import FilterDict, SanitizingJSONEncoder from bugsnag.event import Event +from bugsnag.request_tracker import RequestTracker __all__ = [] # type: List[str] @@ -36,6 +37,7 @@ def __init__(self, configuration): self.mutex = Lock() self.auto_sessions = False self.delivery_thread = None + self._request_tracker = RequestTracker() def start_session(self): if not self.auto_sessions: @@ -138,15 +140,25 @@ def __deliver(self, sessions: List[Dict], asynchronous=True): deliver = self.config.delivery.deliver_sessions if 'options' in deliver.__code__.co_varnames: - deliver( - self.config, - encoded_payload, - options={'asynchronous': asynchronous} - ) + try: + post_delivery_callback = self._request_tracker.new_request() + + deliver( + self.config, + encoded_payload, + options={ + 'asynchronous': asynchronous, + 'post_delivery_callback': post_delivery_callback, + } + ) + except Exception: + # ensure the request is not still marked as pending + post_delivery_callback() + raise + else: deliver(self.config, encoded_payload) - except Exception as e: self.config.logger.exception('Sending sessions failed %s', e) diff --git a/bugsnag/utils.py b/bugsnag/utils.py index 76aab0a8..c2e1a857 100644 --- a/bugsnag/utils.py +++ b/bugsnag/utils.py @@ -261,7 +261,7 @@ def is_json_content_type(value: str) -> bool: return type == 'application' and (subtype == 'json' or suffix == 'json') -_ignore_modules = ('__main__', 'builtins') +_ignore_modules = ('__main__', 'builtins', 'bugsnag.client') def partly_qualified_class_name(obj): diff --git a/features/aws-lambda/handled.feature b/features/aws-lambda/handled.feature new file mode 100644 index 00000000..5c3b22ad --- /dev/null +++ b/features/aws-lambda/handled.feature @@ -0,0 +1,44 @@ +Feature: Handled exceptions in AWS Lambda + +# 3.9 is currently the minimum python version with a lambda runtime +@not-python-3.5 @not-python-3.6 @not-python-3.7 @not-python-3.8 +Scenario: Handled exceptions are delivered in an AWS Lambda app + Given I run the lambda handler "handled" with the "event.json" event + When 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 event "unhandled" is false + And the event "severity" equals "warning" + And the event "severityReason.type" equals "handledException" + And the exception "errorClass" equals "Exception" + And the exception "message" equals "hello there" + And the exception "type" equals "python" + And the "file" of stack frame 0 equals "handled.py" + And the event "metaData.AWS Lambda Context.function_name" equals "BugsnagAwsLambdaTestFunction" + And the event "metaData.AWS Lambda Context.aws_request_id" is not null + And the event "metaData.AWS Lambda Event.path" equals "/hello" + And the event "metaData.AWS Lambda Event.httpMethod" equals "GET" + When I wait to receive a session + Then the session is valid for the session reporting API version "4.0" for the "Python Bugsnag Notifier" notifier + And the session payload has a valid sessions array + And the sessionCount "sessionsStarted" equals 1 + +@not-python-3.5 @not-python-3.6 @not-python-3.7 @not-python-3.8 +Scenario: Handled exceptions are delivered in an AWS Lambda app when auto_notify is False + Given I run the lambda handler "handled_no_auto_notify" with the "event.json" event + When 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 event "unhandled" is false + And the event "severity" equals "warning" + And the event "severityReason.type" equals "handledException" + And the exception "errorClass" equals "Exception" + And the exception "message" equals "hi friend" + And the exception "type" equals "python" + And the "file" of stack frame 0 equals "handled_no_auto_notify.py" + And the event "metaData.AWS Lambda Context.function_name" equals "BugsnagAwsLambdaTestFunction" + And the event "metaData.AWS Lambda Context.aws_request_id" is not null + And the event "metaData.AWS Lambda Event.path" equals "/hello" + And the event "metaData.AWS Lambda Event.httpMethod" equals "GET" + When I wait to receive a session + Then the session is valid for the session reporting API version "4.0" for the "Python Bugsnag Notifier" notifier + And the session payload has a valid sessions array + And the sessionCount "sessionsStarted" equals 1 diff --git a/features/aws-lambda/sessions.feature b/features/aws-lambda/sessions.feature new file mode 100644 index 00000000..7c90e0ab --- /dev/null +++ b/features/aws-lambda/sessions.feature @@ -0,0 +1,19 @@ +Feature: Sessions in AWS Lambda + +@not-python-3.5 @not-python-3.6 @not-python-3.7 @not-python-3.8 +Scenario: Manually started sessions are delivered in an AWS Lambda app when auto_capture_sessions is True + Given I run the lambda handler "manual_session" with the "event.json" event + When I wait to receive a session + Then the session is valid for the session reporting API version "4.0" for the "Python Bugsnag Notifier" notifier + And the session payload has a valid sessions array + And the sessionCount "sessionsStarted" equals 2 + And I should receive no errors + +@not-python-3.5 @not-python-3.6 @not-python-3.7 @not-python-3.8 +Scenario: Manually started sessions are delivered in an AWS Lambda app when auto_capture_sessions is False + Given I run the lambda handler "manual_session_no_auto_capture_sessions" with the "event.json" event + When I wait to receive a session + Then the session is valid for the session reporting API version "4.0" for the "Python Bugsnag Notifier" notifier + And the session payload has a valid sessions array + And the sessionCount "sessionsStarted" equals 1 + And I should receive no errors diff --git a/features/aws-lambda/unhandled.feature b/features/aws-lambda/unhandled.feature new file mode 100644 index 00000000..a3216521 --- /dev/null +++ b/features/aws-lambda/unhandled.feature @@ -0,0 +1,31 @@ +Feature: Unhandled exceptions in AWS Lambda + +@not-python-3.5 @not-python-3.6 @not-python-3.7 @not-python-3.8 +Scenario: Unhandled exceptions are delivered in an AWS Lambda app + Given I run the lambda handler "unhandled" with the "event.json" event + When 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 event "unhandled" is true + And the event "severity" equals "error" + And the event "severityReason.type" equals "unhandledException" + And the exception "errorClass" equals "Exception" + And the exception "message" equals "uh oh!" + And the exception "type" equals "python" + And the "file" of stack frame 0 equals "unhandled.py" + And the event "metaData.AWS Lambda Context.function_name" equals "BugsnagAwsLambdaTestFunction" + And the event "metaData.AWS Lambda Context.aws_request_id" is not null + And the event "metaData.AWS Lambda Event.path" equals "/hello" + And the event "metaData.AWS Lambda Event.httpMethod" equals "GET" + When I wait to receive a session + Then the session is valid for the session reporting API version "4.0" for the "Python Bugsnag Notifier" notifier + And the session payload has a valid sessions array + And the sessionCount "sessionsStarted" equals 1 + +@not-python-3.5 @not-python-3.6 @not-python-3.7 @not-python-3.8 +Scenario: Unhandled exceptions are not delivered in an AWS Lambda app when auto_detect_errors is False + Given I run the lambda handler "unhandled_no_auto_notify" with the "event.json" event + When I wait to receive a session + Then the session is valid for the session reporting API version "4.0" for the "Python Bugsnag Notifier" notifier + And the session payload has a valid sessions array + And the sessionCount "sessionsStarted" equals 1 + And I should receive no errors diff --git a/features/fixtures/.gitignore b/features/fixtures/.gitignore new file mode 100644 index 00000000..270502f2 --- /dev/null +++ b/features/fixtures/.gitignore @@ -0,0 +1 @@ +temp-bugsnag-python/ diff --git a/features/fixtures/aws-lambda/Dockerfile b/features/fixtures/aws-lambda/Dockerfile new file mode 100644 index 00000000..55d953a6 --- /dev/null +++ b/features/fixtures/aws-lambda/Dockerfile @@ -0,0 +1,10 @@ +ARG PYTHON_TEST_VERSION +FROM python:$PYTHON_TEST_VERSION + +# install the SAM CLI +ENV SAM_CLI_TELEMETRY=0 +RUN pip install --upgrade pip && pip install aws-sam-cli + +COPY temp-bugsnag-python/ /usr/src/bugsnag + +WORKDIR /usr/src/app diff --git a/features/fixtures/aws-lambda/app/events/event.json b/features/fixtures/aws-lambda/app/events/event.json new file mode 100644 index 00000000..a6197dea --- /dev/null +++ b/features/fixtures/aws-lambda/app/events/event.json @@ -0,0 +1,62 @@ +{ + "body": "{\"message\": \"hello world\"}", + "resource": "/hello", + "path": "/hello", + "httpMethod": "GET", + "isBase64Encoded": false, + "queryStringParameters": { + "foo": "bar" + }, + "pathParameters": { + "proxy": "/path/to/resource" + }, + "stageVariables": { + "baz": "qux" + }, + "headers": { + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8", + "Accept-Encoding": "gzip, deflate, sdch", + "Accept-Language": "en-US,en;q=0.8", + "Cache-Control": "max-age=0", + "CloudFront-Forwarded-Proto": "https", + "CloudFront-Is-Desktop-Viewer": "true", + "CloudFront-Is-Mobile-Viewer": "false", + "CloudFront-Is-SmartTV-Viewer": "false", + "CloudFront-Is-Tablet-Viewer": "false", + "CloudFront-Viewer-Country": "US", + "Host": "1234567890.execute-api.us-east-1.amazonaws.com", + "Upgrade-Insecure-Requests": "1", + "User-Agent": "Custom User Agent String", + "Via": "1.1 08f323deadbeefa7af34d5feb414ce27.cloudfront.net (CloudFront)", + "X-Amz-Cf-Id": "cDehVQoZnx43VYQb9j2-nvCh-9z396Uhbp027Y2JvkCPNLmGJHqlaA==", + "X-Forwarded-For": "127.0.0.1, 127.0.0.2", + "X-Forwarded-Port": "443", + "X-Forwarded-Proto": "https" + }, + "requestContext": { + "accountId": "123456789012", + "resourceId": "123456", + "stage": "prod", + "requestId": "c6af9ac6-7b61-11e6-9a41-93e8deadbeef", + "requestTime": "09/Apr/2015:12:34:56 +0000", + "requestTimeEpoch": 1428582896000, + "identity": { + "cognitoIdentityPoolId": null, + "accountId": null, + "cognitoIdentityId": null, + "caller": null, + "accessKey": null, + "sourceIp": "127.0.0.1", + "cognitoAuthenticationType": null, + "cognitoAuthenticationProvider": null, + "userArn": null, + "userAgent": "Custom User Agent String", + "user": null + }, + "path": "/prod/hello", + "resourcePath": "/hello", + "httpMethod": "POST", + "apiId": "1234567890", + "protocol": "HTTP/1.1" + } +} diff --git a/features/fixtures/aws-lambda/app/src/__init__.py b/features/fixtures/aws-lambda/app/src/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/features/fixtures/aws-lambda/app/src/handled.py b/features/fixtures/aws-lambda/app/src/handled.py new file mode 100644 index 00000000..34ca031a --- /dev/null +++ b/features/fixtures/aws-lambda/app/src/handled.py @@ -0,0 +1,22 @@ +import os +import json +import bugsnag + + +bugsnag.configure( + api_key=os.environ["BUGSNAG_API_KEY"], + endpoint=os.environ["BUGSNAG_ERROR_ENDPOINT"], + session_endpoint=os.environ["BUGSNAG_SESSION_ENDPOINT"], +) + + +@bugsnag.aws_lambda_handler +def handler(event, context): + bugsnag.notify(Exception("hello there")) + + return { + "statusCode": 200, + "body": json.dumps({ + "message": "Did not crash!", + }), + } diff --git a/features/fixtures/aws-lambda/app/src/handled_no_auto_notify.py b/features/fixtures/aws-lambda/app/src/handled_no_auto_notify.py new file mode 100644 index 00000000..cd6d6f64 --- /dev/null +++ b/features/fixtures/aws-lambda/app/src/handled_no_auto_notify.py @@ -0,0 +1,23 @@ +import os +import json +import bugsnag + + +bugsnag.configure( + api_key=os.environ["BUGSNAG_API_KEY"], + endpoint=os.environ["BUGSNAG_ERROR_ENDPOINT"], + session_endpoint=os.environ["BUGSNAG_SESSION_ENDPOINT"], + auto_notify=False, +) + + +@bugsnag.aws_lambda_handler +def handler(event, context): + bugsnag.notify(Exception("hi friend")) + + return { + "statusCode": 200, + "body": json.dumps({ + "message": "Did not crash!", + }), + } diff --git a/features/fixtures/aws-lambda/app/src/manual_session.py b/features/fixtures/aws-lambda/app/src/manual_session.py new file mode 100644 index 00000000..ef59eae6 --- /dev/null +++ b/features/fixtures/aws-lambda/app/src/manual_session.py @@ -0,0 +1,22 @@ +import os +import json +import bugsnag + + +bugsnag.configure( + api_key=os.environ["BUGSNAG_API_KEY"], + endpoint=os.environ["BUGSNAG_ERROR_ENDPOINT"], + session_endpoint=os.environ["BUGSNAG_SESSION_ENDPOINT"], +) + + +@bugsnag.aws_lambda_handler +def handler(event, context): + bugsnag.start_session() + + return { + "statusCode": 200, + "body": json.dumps({ + "message": "Did not crash!", + }), + } diff --git a/features/fixtures/aws-lambda/app/src/manual_session_no_auto_capture_sessions.py b/features/fixtures/aws-lambda/app/src/manual_session_no_auto_capture_sessions.py new file mode 100644 index 00000000..aef4eac1 --- /dev/null +++ b/features/fixtures/aws-lambda/app/src/manual_session_no_auto_capture_sessions.py @@ -0,0 +1,23 @@ +import os +import json +import bugsnag + + +bugsnag.configure( + api_key=os.environ["BUGSNAG_API_KEY"], + endpoint=os.environ["BUGSNAG_ERROR_ENDPOINT"], + session_endpoint=os.environ["BUGSNAG_SESSION_ENDPOINT"], + auto_capture_sessions=False, +) + + +@bugsnag.aws_lambda_handler +def handler(event, context): + bugsnag.start_session() + + return { + "statusCode": 200, + "body": json.dumps({ + "message": "Did not crash!", + }), + } diff --git a/features/fixtures/aws-lambda/app/src/requirements.txt b/features/fixtures/aws-lambda/app/src/requirements.txt new file mode 100644 index 00000000..d7f6a11b --- /dev/null +++ b/features/fixtures/aws-lambda/app/src/requirements.txt @@ -0,0 +1 @@ +/usr/src/bugsnag diff --git a/features/fixtures/aws-lambda/app/src/unhandled.py b/features/fixtures/aws-lambda/app/src/unhandled.py new file mode 100644 index 00000000..978c6de5 --- /dev/null +++ b/features/fixtures/aws-lambda/app/src/unhandled.py @@ -0,0 +1,15 @@ +import os +import json +import bugsnag + + +bugsnag.configure( + api_key=os.environ["BUGSNAG_API_KEY"], + endpoint=os.environ["BUGSNAG_ERROR_ENDPOINT"], + session_endpoint=os.environ["BUGSNAG_SESSION_ENDPOINT"], +) + + +@bugsnag.aws_lambda_handler +def handler(event, context): + raise Exception("uh oh!") diff --git a/features/fixtures/aws-lambda/app/src/unhandled_no_auto_notify.py b/features/fixtures/aws-lambda/app/src/unhandled_no_auto_notify.py new file mode 100644 index 00000000..727c7aed --- /dev/null +++ b/features/fixtures/aws-lambda/app/src/unhandled_no_auto_notify.py @@ -0,0 +1,16 @@ +import os +import json +import bugsnag + + +bugsnag.configure( + api_key=os.environ["BUGSNAG_API_KEY"], + endpoint=os.environ["BUGSNAG_ERROR_ENDPOINT"], + session_endpoint=os.environ["BUGSNAG_SESSION_ENDPOINT"], + auto_notify=False, +) + + +@bugsnag.aws_lambda_handler +def handler(event, context): + raise Exception("uh oh!") diff --git a/features/fixtures/aws-lambda/app/template.yaml b/features/fixtures/aws-lambda/app/template.yaml new file mode 100644 index 00000000..90ba2b16 --- /dev/null +++ b/features/fixtures/aws-lambda/app/template.yaml @@ -0,0 +1,42 @@ +AWSTemplateFormatVersion: '2010-09-09' +Transform: AWS::Serverless-2016-10-31 +Description: app + +Globals: + Function: + Timeout: 30 + Environment: + Variables: + BUGSNAG_API_KEY: + BUGSNAG_ERROR_ENDPOINT: + BUGSNAG_SESSION_ENDPOINT: + +Parameters: + Runtime: + Type: String + AllowedPattern: python3\.\d+ + ConstraintDescription: Must be a valid python runtime, e.g. "python3.12" + Handler: + Type: String + AllowedValues: + - handled + - handled_no_auto_notify + - unhandled + - unhandled_no_auto_notify + - manual_session + - manual_session_no_auto_capture_sessions + ConstraintDescription: Must be a file in the 'src' directory with a function named 'handler' + +Resources: + BugsnagAwsLambdaTestFunction: + Type: AWS::Serverless::Function + Properties: + CodeUri: src/ + Handler: !Sub ${Handler}.handler + Runtime: !Ref Runtime + Events: + HelloWorld: + Type: Api + Properties: + Path: /hello + Method: get diff --git a/features/fixtures/docker-compose.yml b/features/fixtures/docker-compose.yml new file mode 100644 index 00000000..c8d4ae6b --- /dev/null +++ b/features/fixtures/docker-compose.yml @@ -0,0 +1,29 @@ +version: "3.8" + +services: + plain: + build: + context: plain + args: + - PYTHON_TEST_VERSION + environment: + - BUGSNAG_API_KEY + - BUGSNAG_ERROR_ENDPOINT + - BUGSNAG_SESSION_ENDPOINT + extra_hosts: + - "host.docker.internal:host-gateway" + + aws-lambda: + build: + context: aws-lambda + args: + - PYTHON_TEST_VERSION + environment: + - BUGSNAG_API_KEY + - BUGSNAG_ERROR_ENDPOINT + - BUGSNAG_SESSION_ENDPOINT + extra_hosts: + - "host.docker.internal:host-gateway" + volumes: + - "/var/run/docker.sock:/var/run/docker.sock" + - "./aws-lambda/app:/usr/src/app" diff --git a/features/fixtures/plain/Dockerfile b/features/fixtures/plain/Dockerfile new file mode 100644 index 00000000..e8cf47e5 --- /dev/null +++ b/features/fixtures/plain/Dockerfile @@ -0,0 +1,9 @@ +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 + +RUN pip install --no-cache-dir -r requirements.txt diff --git a/features/fixtures/plain/app/handled.py b/features/fixtures/plain/app/handled.py new file mode 100644 index 00000000..eec8395f --- /dev/null +++ b/features/fixtures/plain/app/handled.py @@ -0,0 +1,10 @@ +import os +import bugsnag + +bugsnag.configure( + api_key=os.environ["BUGSNAG_API_KEY"], + endpoint=os.environ["BUGSNAG_ERROR_ENDPOINT"], + session_endpoint=os.environ["BUGSNAG_SESSION_ENDPOINT"], +) + +bugsnag.notify(RuntimeError("uh oh :o")) diff --git a/features/fixtures/plain/app/requirements.txt b/features/fixtures/plain/app/requirements.txt new file mode 100644 index 00000000..d10c3509 --- /dev/null +++ b/features/fixtures/plain/app/requirements.txt @@ -0,0 +1 @@ +../bugsnag diff --git a/features/fixtures/plain/app/unhandled.py b/features/fixtures/plain/app/unhandled.py new file mode 100644 index 00000000..a47b973a --- /dev/null +++ b/features/fixtures/plain/app/unhandled.py @@ -0,0 +1,10 @@ +import os +import bugsnag + +bugsnag.configure( + api_key=os.environ["BUGSNAG_API_KEY"], + endpoint=os.environ["BUGSNAG_ERROR_ENDPOINT"], + session_endpoint=os.environ["BUGSNAG_SESSION_ENDPOINT"], +) + +raise Exception("OH NO!") diff --git a/features/plain/handled.feature b/features/plain/handled.feature new file mode 100644 index 00000000..54ad23a8 --- /dev/null +++ b/features/plain/handled.feature @@ -0,0 +1,11 @@ +Feature: Handled exceptions + +Scenario: Handled exceptions are delivered + Given I run the service "plain" with the command "python handled.py" + 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 "RuntimeError" + And the exception "message" equals "uh oh :o" + And the event "unhandled" is false + And the event "severity" equals "warning" + And the event "severityReason.type" equals "handledException" diff --git a/features/plain/unhandled.feature b/features/plain/unhandled.feature new file mode 100644 index 00000000..192e33ae --- /dev/null +++ b/features/plain/unhandled.feature @@ -0,0 +1,11 @@ +Feature: Unhandled exceptions + +Scenario: Unhandled exceptions are delivered + Given I run the service "plain" with the command "python unhandled.py" + 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 "OH NO!" + And the event "unhandled" is true + And the event "severity" equals "error" + And the event "severityReason.type" equals "unhandledException" diff --git a/features/steps/steps.rb b/features/steps/steps.rb new file mode 100644 index 00000000..eb262ed0 --- /dev/null +++ b/features/steps/steps.rb @@ -0,0 +1,43 @@ +require "os" + +def parameter_overrides(handler_name = nil) + overrides = [ + "--parameter-overrides", + # PYTHON_TEST_VERSION is defined in env.rb + "ParameterKey=Runtime,ParameterValue=python#{PYTHON_TEST_VERSION}", + ] + + overrides << "ParameterKey=Handler,ParameterValue=#{handler_name}" unless handler_name.nil? + + overrides.join(" ") +end + +Given("I build the lambda function") do + step(%Q{I run the service "aws-lambda" with the command "sam build BugsnagAwsLambdaTestFunction #{parameter_overrides}"}) +end + +Given("I invoke the lambda handler {string}") do |handle_name| + command = [ + "sam local invoke BugsnagAwsLambdaTestFunction", + "--container-host #{current_ip}", + "--container-host-interface 0.0.0.0", + "--docker-volume-basedir $PWD/features/fixtures/aws-lambda/app/.aws-sam/build", + parameter_overrides(handle_name), + ] + + step(%Q{I run the service "aws-lambda" with the command "#{command.join(" ")}"}) +end + +Given("I run the lambda function {string}") do |handler_name| + steps(%Q{ + Given I build the lambda function + And I invoke the lambda handler "#{handler_name}" + }) +end + +Given("I run the lambda handler {string} with the {string} event") do |handler_name, event| + steps(%Q{ + Given I build the lambda function + And I invoke the lambda handler "#{handler_name} --event events/#{event}" + }) +end diff --git a/features/support/env.rb b/features/support/env.rb new file mode 100644 index 00000000..9f3ae675 --- /dev/null +++ b/features/support/env.rb @@ -0,0 +1,60 @@ +require "fileutils" + +PYTHON_TEST_VERSION = ENV.fetch("PYTHON_TEST_VERSION") + +def current_ip + return "host.docker.internal" if OS.mac? + + ip_addr = `ifconfig | grep -Eo 'inet (addr:)?([0-9]*\\\.){3}[0-9]*' | grep -v '127.0.0.1'` + ip_list = /((?:[0-9]*\.){3}[0-9]*)/.match(ip_addr) + ip_list.captures.first +end + +Maze.hooks.before_all do + # log to console, not the filesystem + Maze.config.file_log = false + Maze.config.log_requests = true + + # don't wait so long for requests/not to receive requests + Maze.config.receive_requests_wait = 10 + Maze.config.receive_no_requests_wait = 10 + + # warn if a test takes more than 5 seconds to send a request + Maze.config.receive_requests_slow_threshold = 5 + + # bugsnag-python doesn't need to send the integrity header + Maze.config.enforce_bugsnag_integrity = false + + # install bugsnag into each fixture + Dir.each_child("features/fixtures") do |path| + fixture_directory = "#{Dir.pwd}/features/fixtures/#{path}" + + next unless File.directory?(fixture_directory) + + destination = "#{fixture_directory}/temp-bugsnag-python" + + FileUtils.mkdir(destination) unless File.exist?(destination) + + FileUtils.cp_r( + ["bugsnag", "setup.py"], + destination, + remove_destination: true # delete destination before copying + ) + + at_exit do + FileUtils.rm_rf(destination) + end + end +end + +Maze.hooks.before do + Maze::Runner.environment["BUGSNAG_API_KEY"] = $api_key + Maze::Runner.environment["BUGSNAG_ERROR_ENDPOINT"] = "http://#{current_ip}:#{Maze.config.port}/notify" + Maze::Runner.environment["BUGSNAG_SESSION_ENDPOINT"] = "http://#{current_ip}:#{Maze.config.port}/sessions" +end + +5.upto(100) do |minor_version| + Before("@not-python-3.#{minor_version}") do + skip_this_scenario if PYTHON_TEST_VERSION == "3.#{minor_version}" + end +end diff --git a/setup.py b/setup.py index fb791d0e..1f821ceb 100755 --- a/setup.py +++ b/setup.py @@ -14,7 +14,7 @@ setup( name='bugsnag', - version='4.6.2', + version='4.7.0', description='Automatic error monitoring for django, flask, etc.', long_description=__doc__, author='Simon Maynard', @@ -39,6 +39,7 @@ 'Programming Language :: Python :: 3.9', 'Programming Language :: Python :: 3.10', 'Programming Language :: Python :: 3.11', + 'Programming Language :: Python :: 3.12', 'Topic :: Software Development' ], package_data={ diff --git a/tests/test_client.py b/tests/test_client.py index f96dcb2b..f01b8589 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1,9 +1,11 @@ import os import re import sys +import time import pytest import inspect import logging +import threading from datetime import datetime, timedelta, timezone from unittest.mock import Mock, ANY from tests import fixtures @@ -17,7 +19,12 @@ ) import bugsnag.legacy as legacy -from tests.utils import IntegrationTest, ScaryException +from tests.utils import ( + BrokenDelivery, + IntegrationTest, + QueueingDelivery, + ScaryException, +) timestamp_regex = r'^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}(?:[+-]\d{2}:\d{2}|Z)' # noqa: E501 @@ -1688,6 +1695,292 @@ def on_error(event): FeatureFlag('e') ] + def test_in_flight_event_request_tracking_in_notify(self): + delivery = QueueingDelivery() + configuration = Configuration() + configuration.configure(delivery=delivery, api_key='abc') + + client = Client(configuration) + assert not client._request_tracker.has_in_flight_requests() + + client.notify(Exception('Oh no')) + assert client._request_tracker.has_in_flight_requests() + + delivery.flush_request_queue() + assert not client._request_tracker.has_in_flight_requests() + + def test_in_flight_event_request_tracking_in_notify_failure(self): + configuration = Configuration() + configuration.configure(delivery=BrokenDelivery(), api_key='abc') + + client = Client(configuration) + assert not client._request_tracker.has_in_flight_requests() + + client.notify(Exception('Oh no')) + assert not client._request_tracker.has_in_flight_requests() + + def test_in_flight_event_request_tracking_in_notify_exc_info(self): + delivery = QueueingDelivery() + configuration = Configuration() + configuration.configure(delivery=delivery, api_key='abc') + + client = Client(configuration) + assert not client._request_tracker.has_in_flight_requests() + + try: + raise Exception(':)') + except Exception: + client.notify_exc_info(*sys.exc_info()) + + assert client._request_tracker.has_in_flight_requests() + + delivery.flush_request_queue() + assert not client._request_tracker.has_in_flight_requests() + + def test_in_flight_event_request_tracking_in_notify_exc_info_failure(self): + configuration = Configuration() + configuration.configure(delivery=BrokenDelivery(), api_key='abc') + + client = Client(configuration) + assert not client._request_tracker.has_in_flight_requests() + + try: + raise Exception(':)') + except Exception: + client.notify_exc_info(*sys.exc_info()) + + assert not client._request_tracker.has_in_flight_requests() + + def test_flush_returns_immediately_when_no_requests_are_outstanding(self): + start_s = time.time() + self.client.flush(10) + end_s = time.time() + + assert end_s - start_s < 0.01 + + def test_flush_raises_if_timeout_is_exceeded(self): + delivery = QueueingDelivery() + configuration = Configuration() + configuration.configure(delivery=delivery, api_key='abc') + + client = Client(configuration) + client.notify(Exception('oh dear')) + + with pytest.raises(Exception) as exception: + client.flush(10) + + assert str(exception) == 'Exception: flush timed out after 10ms' + + def test_flush_waits_for_outstanding_events_before_returning(self): + delivery = QueueingDelivery() + configuration = Configuration() + configuration.configure(delivery=delivery, api_key='abc') + + client = Client(configuration) + client.notify(Exception('oh dear')) + client.notify(Exception('oh no')) + client.notify(Exception('oh my')) + + def flush_request_queue(): + time.sleep(0.05) + assert client._request_tracker.has_in_flight_requests() + + delivery.flush_request_queue() + assert not client._request_tracker.has_in_flight_requests() + + thread = threading.Thread(target=flush_request_queue) + thread.start() + + client.flush(100) + + # the thread should have stopped before flush could exit + assert not thread.is_alive() + + def test_flush_waits_for_outstanding_sessions_before_returning(self): + delivery = QueueingDelivery() + configuration = Configuration() + configuration.configure(delivery=delivery, api_key='abc') + + client = Client(configuration) + client.session_tracker.start_session() + client.session_tracker.start_session() + + def flush_request_queue(): + request_tracker = client.session_tracker._request_tracker + + time.sleep(0.05) + assert request_tracker.has_in_flight_requests() + + delivery.flush_request_queue() + assert not request_tracker.has_in_flight_requests() + + thread = threading.Thread(target=flush_request_queue) + thread.start() + + client.flush(100) + + # the thread should have stopped before flush could exit + assert not thread.is_alive() + + def test_aws_lambda_handler_decorator(self): + aws_lambda_context = LambdaContext(function_name='abcdef') + + @self.client.aws_lambda_handler + def my_handler(event, context): + assert event == {'a': 1} + assert context == aws_lambda_context + + raise Exception('oh dear') + + with pytest.raises(Exception) as exception: + my_handler({'a': 1}, aws_lambda_context) + + assert str(exception) == 'Exception: oh dear' + + assert self.sent_report_count == 1 + assert self.sent_session_count == 1 + + payload = self.server.events_received[0]['json_body'] + event = payload['events'][0] + + assert event['unhandled'] + assert event['exceptions'][0]['message'] == 'oh dear' + assert event['metaData']['AWS Lambda Event'] == {'a': 1} + assert event['metaData']['AWS Lambda Context'] == { + 'function_name': 'abcdef', + 'function_version': 'function_version', + 'invoked_function_arn': 'invoked_function_arn', + 'memory_limit_in_mb': 'memory_limit_in_mb', + 'aws_request_id': 'aws_request_id', + 'log_group_name': 'log_group_name', + 'log_stream_name': 'log_stream_name', + 'identity': 'identity', + 'client_context': 'client_context', + } + + def test_aws_lambda_handler_decorator_accepts_flush_timeout(self): + aws_lambda_context = LambdaContext(function_version='$LATEST') + + @self.client.aws_lambda_handler(flush_timeout_ms=1000) + def my_handler(event, context): + assert event == {'z': 9} + assert context == aws_lambda_context + + raise Exception('oh dear') + + with pytest.raises(Exception) as exception: + my_handler({'z': 9}, aws_lambda_context) + + assert str(exception) == 'Exception: oh dear' + + assert self.sent_report_count == 1 + assert self.sent_session_count == 1 + + payload = self.server.events_received[0]['json_body'] + event = payload['events'][0] + + assert event['unhandled'] + assert event['exceptions'][0]['message'] == 'oh dear' + assert event['metaData']['AWS Lambda Event'] == {'z': 9} + assert event['metaData']['AWS Lambda Context'] == { + 'function_name': 'function_name', + 'function_version': '$LATEST', + 'invoked_function_arn': 'invoked_function_arn', + 'memory_limit_in_mb': 'memory_limit_in_mb', + 'aws_request_id': 'aws_request_id', + 'log_group_name': 'log_group_name', + 'log_stream_name': 'log_stream_name', + 'identity': 'identity', + 'client_context': 'client_context', + } + + def test_aws_lambda_handler_decorator_warns_after_timeout(self): + aws_lambda_context = LambdaContext() + client = Client(delivery=QueueingDelivery(), api_key='abc') + + @client.aws_lambda_handler(flush_timeout_ms=50) + def my_handler(event, context): + assert event == {'z': 9} + assert context == aws_lambda_context + + raise Exception('oh dear') + + with pytest.warns(UserWarning) as warnings: + with pytest.raises(Exception) as exception: + my_handler({'z': 9}, aws_lambda_context) + + assert str(exception) == 'Exception: oh dear' + + assert len(warnings) == 1 + assert warnings[0].message.args[0] == \ + 'Delivery may be unsuccessful: flush timed out after 50ms' + + assert self.sent_report_count == 0 + assert self.sent_session_count == 0 + + def test_aws_lambda_handler_decorator_warns_of_potential_timeout(self): + aws_lambda_context = LambdaContext(remaining_time_in_millis=2) + + @self.client.aws_lambda_handler(lambda_timeout_notify_ms=1) + def my_handler(event, context): + assert event == {'z': 9} + assert context == aws_lambda_context + + self.client.leave_breadcrumb('hello 1') + self.client.leave_breadcrumb('hello 2') + self.client.leave_breadcrumb('hello 3') + + self.client.add_feature_flag('a') + self.client.add_feature_flag('b', '1') + self.client.add_feature_flag('c') + + time.sleep(0.1) + + my_handler({'z': 9}, aws_lambda_context) + + assert self.sent_report_count == 1 + assert self.sent_session_count == 1 + + payload = self.server.events_received[0]['json_body'] + event = payload['events'][0] + + assert event['metaData']['AWS Lambda Event'] == {'z': 9} + assert event['metaData']['AWS Lambda Context'] == { + 'function_name': 'function_name', + 'function_version': 'function_version', + 'invoked_function_arn': 'invoked_function_arn', + 'memory_limit_in_mb': 'memory_limit_in_mb', + 'aws_request_id': 'aws_request_id', + 'log_group_name': 'log_group_name', + 'log_stream_name': 'log_stream_name', + 'identity': 'identity', + 'client_context': 'client_context', + } + + assert len(event['breadcrumbs']) == 3 + assert event['breadcrumbs'][0]['name'] == 'hello 1' + assert event['breadcrumbs'][1]['name'] == 'hello 2' + assert event['breadcrumbs'][2]['name'] == 'hello 3' + + assert event['featureFlags'] == [ + {'featureFlag': 'a'}, + {'featureFlag': 'b', 'variant': '1'}, + {'featureFlag': 'c'}, + ] + + exception = event['exceptions'][0] + + assert exception['message'] == 'Lambda will timeout in 2ms' + assert exception['errorClass'] == 'LambdaTimeoutApproaching' + + # the stacktrace should have a single frame pointing to the user's + # lambda handler + stacktrace = exception['stacktrace'] + + assert len(stacktrace) == 1 + assert stacktrace[0]['file'] == 'test_client.py' + assert stacktrace[0]['method'] == 'my_handler' + @pytest.mark.parametrize("metadata,type", [ (1234, 'int'), @@ -1718,3 +2011,33 @@ def test_breadcrumb_metadata_is_coerced_to_dict(metadata, type): assert breadcrumb.metadata == {} assert breadcrumb.type == BreadcrumbType.MANUAL assert is_valid_timestamp(breadcrumb.timestamp) + + +class LambdaContext: + def __init__( + self, + function_name='function_name', + function_version='function_version', + invoked_function_arn='invoked_function_arn', + memory_limit_in_mb='memory_limit_in_mb', + aws_request_id='aws_request_id', + log_group_name='log_group_name', + log_stream_name='log_stream_name', + identity='identity', + client_context='client_context', + remaining_time_in_millis=10000 + ): + self.function_name = function_name + self.function_version = function_version + self.invoked_function_arn = invoked_function_arn + self.memory_limit_in_mb = memory_limit_in_mb + self.aws_request_id = aws_request_id + self.log_group_name = log_group_name + self.log_stream_name = log_stream_name + self.identity = identity + self.client_context = client_context + self.another_attribute = 'another_attribute' + self.remaining_time_in_millis = remaining_time_in_millis + + def get_remaining_time_in_millis(self) -> int: + return self.remaining_time_in_millis diff --git a/tests/test_delivery.py b/tests/test_delivery.py index 798b9320..591bcfd1 100644 --- a/tests/test_delivery.py +++ b/tests/test_delivery.py @@ -1,3 +1,4 @@ +import pytest import warnings import sys @@ -9,7 +10,7 @@ DEFAULT_SESSIONS_ENDPOINT ) -from tests.utils import IntegrationTest +from tests.utils import BrokenDelivery, IntegrationTest, QueueingDelivery class DeliveryTest(IntegrationTest): @@ -64,3 +65,135 @@ def test_misconfigured_sessions_endpoint_sends_warning(self): delivery.deliver_sessions(self.config, '{"apiKey":"aaab"}') self.assertEqual(1, len(warn)) self.assertEqual(0, len(self.server.events_received)) + + def test_it_calls_post_delivery_callback(self): + callback_was_called = False + + def post_delivery_callback(): + nonlocal callback_was_called + callback_was_called = True + + self.config.delivery.deliver( + self.config, + '{"legit": 5}', + options={'post_delivery_callback': post_delivery_callback} + ) + + assert callback_was_called + + def test_it_calls_post_delivery_callback_after_success(self): + delivery = QueueingDelivery() + self.config.configure(delivery=delivery) + + callback_was_called = False + + def post_delivery_callback(): + nonlocal callback_was_called + callback_was_called = True + + delivery.deliver( + self.config, + '{"legit": 5}', + options={'post_delivery_callback': post_delivery_callback} + ) + + assert not callback_was_called + + delivery.flush_request_queue() + assert callback_was_called + + def test_it_calls_post_delivery_callback_on_failure(self): + self.config.configure(delivery=BrokenDelivery()) + + callback_was_called = False + + def post_delivery_callback(): + nonlocal callback_was_called + callback_was_called = True + + with pytest.raises(Exception): + self.config.delivery.deliver( + self.config, + '{"legit": 6}', + options={'post_delivery_callback': post_delivery_callback} + ) + + assert callback_was_called + + def test_it_calls_post_delivery_callback_for_sessions(self): + callback_was_called = False + + def post_delivery_callback(): + nonlocal callback_was_called + callback_was_called = True + + self.config.delivery.deliver_sessions( + self.config, + '{"legit": 7}', + options={'post_delivery_callback': post_delivery_callback} + ) + + assert callback_was_called + + def test_it_calls_post_delivery_callback_for_sessions_after_success(self): + delivery = QueueingDelivery() + self.config.configure(delivery=delivery) + + callback_was_called = False + + def post_delivery_callback(): + nonlocal callback_was_called + callback_was_called = True + + delivery.deliver_sessions( + self.config, + '{"legit": 5}', + options={'post_delivery_callback': post_delivery_callback} + ) + + assert not callback_was_called + + delivery.flush_request_queue() + assert callback_was_called + + def test_it_calls_post_delivery_callback_for_sessions_on_failure(self): + self.config.configure(delivery=BrokenDelivery()) + + callback_was_called = False + + def post_delivery_callback(): + nonlocal callback_was_called + callback_was_called = True + + with pytest.raises(Exception): + self.config.delivery.deliver_sessions( + self.config, + '{"legit": 8}', + options={'post_delivery_callback': post_delivery_callback} + ) + + assert callback_was_called + + def test_it_handles_invalid_post_delivery_callback(self): + self.config.delivery.deliver( + self.config, + '{"legit": 5}', + options={'post_delivery_callback': 'not callable :)'} + ) + + def test_it_handles_errors_in_post_delivery_callback(self): + callback_was_called = False + + def post_delivery_callback(): + nonlocal callback_was_called + callback_was_called = True + + raise Exception('oh dear') + + self.config.delivery.deliver( + self.config, + '{"legit": 5}', + options={'post_delivery_callback': post_delivery_callback} + ) + + assert callback_was_called diff --git a/tests/test_request_tracker.py b/tests/test_request_tracker.py new file mode 100644 index 00000000..c03826e0 --- /dev/null +++ b/tests/test_request_tracker.py @@ -0,0 +1,60 @@ +from bugsnag.request_tracker import RequestTracker + + +def test_a_request_can_be_tracked(): + tracker = RequestTracker() + assert not tracker.has_in_flight_requests() + + tracker.new_request() + assert tracker.has_in_flight_requests() + + +def test_a_request_can_be_marked_as_complete(): + tracker = RequestTracker() + assert not tracker.has_in_flight_requests() + + complete_request = tracker.new_request() + assert tracker.has_in_flight_requests() + + complete_request() + assert not tracker.has_in_flight_requests() + + +def test_requests_can_be_marked_as_complete(): + tracker = RequestTracker() + + complete_request_1 = tracker.new_request() + complete_request_2 = tracker.new_request() + complete_request_3 = tracker.new_request() + + assert tracker.has_in_flight_requests() + + complete_request_1() + complete_request_2() + + assert tracker.has_in_flight_requests() + + complete_request_3() + assert not tracker.has_in_flight_requests() + + +def test_callbacks_can_be_called_multiple_times(): + tracker = RequestTracker() + assert not tracker.has_in_flight_requests() + + complete_request_1 = tracker.new_request() + complete_request_2 = tracker.new_request() + + assert tracker.has_in_flight_requests() + + complete_request_1() + complete_request_1() + complete_request_1() + + assert tracker.has_in_flight_requests() + + complete_request_2() + assert not tracker.has_in_flight_requests() + + complete_request_2() + assert not tracker.has_in_flight_requests() diff --git a/tests/test_sessiontracker.py b/tests/test_sessiontracker.py index db2b41a6..b11dd6a6 100644 --- a/tests/test_sessiontracker.py +++ b/tests/test_sessiontracker.py @@ -5,7 +5,7 @@ from bugsnag.configuration import Configuration from bugsnag.notifier import _NOTIFIER_INFORMATION from bugsnag.sessiontracker import SessionTracker -from tests.utils import IntegrationTest +from tests.utils import BrokenDelivery, IntegrationTest, QueueingDelivery from unittest.mock import Mock @@ -192,3 +192,37 @@ def test_session_tracker_starts_delivery_when_auto_capture_is_off(self): self.server.wait_for_session() assert self.server.sent_session_count == 1 + + def test_session_tracker_tracks_in_flight_requests(self): + delivery = QueueingDelivery() + client = Client( + api_key='a05afff2bd2ffaf0ab0f52715bbdcffd', + auto_capture_sessions=False, + session_endpoint=self.server.sessions_url, + asynchronous=False, + delivery=delivery, + ) + + client.session_tracker.start_session() + client.session_tracker.send_sessions() + + request_tracker = client.session_tracker._request_tracker + assert request_tracker.has_in_flight_requests() + + delivery.flush_request_queue() + assert not request_tracker.has_in_flight_requests() + + def test_session_tracker_tracks_in_flight_requests_failure(self): + client = Client( + api_key='a05afff2bd2ffaf0ab0f52715bbdcffd', + auto_capture_sessions=False, + session_endpoint=self.server.sessions_url, + asynchronous=False, + delivery=BrokenDelivery(), + ) + + client.session_tracker.start_session() + client.session_tracker.send_sessions() + + request_tracker = client.session_tracker._request_tracker + assert not request_tracker.has_in_flight_requests() diff --git a/tests/utils.py b/tests/utils.py index 441cd157..d17e138e 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -7,6 +7,8 @@ from http.server import SimpleHTTPRequestHandler, HTTPServer import bugsnag +from bugsnag.delivery import Delivery +from bugsnag.configuration import RequestConfiguration try: @@ -31,6 +33,7 @@ def setUp(self): self.server.sessions_received = [] def tearDown(self): + RequestConfiguration.get_instance().clear() previous_client = bugsnag.legacy.default_client previous_client.uninstall_sys_hook() @@ -176,3 +179,27 @@ def sent_session_count(self) -> int: class ScaryException(Exception): class NestedScaryException(Exception): pass + + +class QueueingDelivery(Delivery): + def __init__(self): + self._queue = [] + + def deliver(self, config, payload, options): + self._queue.append(options['post_delivery_callback']) + + def flush_request_queue(self): + for callback in self._queue: + callback() + + self._queue.clear() + + +class BrokenDelivery(Delivery): + def deliver(self, config, payload, options): + def request(): + raise Exception('broken!') + + options['asynchronous'] = False + + self.queue_request(request, config, options) diff --git a/tox.ini b/tox.ini index fc85843e..61ab3eac 100644 --- a/tox.ini +++ b/tox.ini @@ -1,17 +1,16 @@ [tox] envlist= - py{35,36,37,38,39,310,311}-{test,requests,flask,tornado,wsgi,bottle} + 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}-{asgi,celery5} - py311-asgi + py{36,37,38,39,310,311,312}-{asgi,celery5} 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} - py{36,37,38,39,310,311}-django3 - py{38,39,310,311}-django4 - py{38,39,310,311}-{asynctest,threadtest} - py{37,38,39,310,311}-exceptiongroup - py{35,311}-{lint} + py{36,37,38,39,310,311,312}-django3 + py{38,39,310,311,312}-django4 + py{38,39,310,311,312}-{asynctest,threadtest} + py{37,38,39,310,311,312}-exceptiongroup + py{35,312}-{lint} [pytest] testpaths = tests @@ -33,6 +32,7 @@ basepython = py39: python3.9 py310: python3.10 py311: python3.11 + py312: python3.12 whitelist_externals= {toxinidir}/scripts/lint.sh deps= @@ -48,7 +48,7 @@ deps= bottle: bottle celery4: celery>=4,<5 celery5: celery>=5,<6 - celery5: pytest-celery + celery5: pytest-celery<1 py37-celery{4,5}: importlib_metadata<5 flask: flask flask: blinker