├── setup.cfg ├── examples ├── django_app │ ├── app │ │ ├── __init__.py │ │ ├── apps.py │ │ ├── urls.py │ │ └── views.py │ ├── honeybadger_example │ │ ├── __init__.py │ │ ├── wsgi.py │ │ ├── urls.py │ │ └── settings.py │ ├── requirements.txt │ ├── manage.py │ └── README.md ├── aws_lambda │ ├── requirements.txt │ ├── handler.py │ └── README.md ├── flask │ ├── requirements.txt │ ├── README.md │ └── app.py ├── flask-blueprint │ ├── requirements.txt │ ├── app.py │ ├── blueprint.py │ └── README.md ├── flask-restplus │ ├── requirements.txt │ ├── README.md │ └── app.py ├── unhandled.py ├── try.py └── fastapi │ ├── app.py │ └── custom_route.py ├── honeybadger ├── tests │ ├── contrib │ │ ├── __init__.py │ │ ├── django_test_app │ │ │ ├── __init__.py │ │ │ ├── views.py │ │ │ ├── middleware.py │ │ │ └── urls.py │ │ ├── test_fastapi.py │ │ ├── test_db.py │ │ ├── test_asgi.py │ │ ├── test_celery.py │ │ └── test_django.py │ ├── payload_fixture.txt │ ├── __init__.py │ ├── test_middleware.py │ ├── test_fake_connection.py │ ├── test_connection.py │ ├── utils.py │ ├── test_utils.py │ ├── test_notice.py │ ├── test_plugins.py │ ├── test_config.py │ ├── test_payload.py │ └── test_events_worker.py ├── version.py ├── types.py ├── middleware.py ├── contrib │ ├── __init__.py │ ├── fastapi.py │ ├── db.py │ ├── logger.py │ ├── asgi.py │ ├── celery.py │ ├── aws_lambda.py │ ├── django.py │ └── flask.py ├── fake_connection.py ├── __init__.py ├── protocols.py ├── context_store.py ├── utils.py ├── notice.py ├── plugins.py ├── connection.py ├── payload.py ├── config.py ├── events_worker.py └── core.py ├── requirements.txt ├── MANIFEST.in ├── .vscode └── settings.json ├── scripts └── install_frameworks.sh ├── pytest.ini ├── .github ├── dependabot.yml └── workflows │ ├── check-pr-title.yml │ ├── code-quality.yml │ ├── pypi-publish.yml │ └── python.yml ├── dev-requirements.txt ├── setup.py ├── LICENSE ├── .gitignore └── CHANGELOG.md /setup.cfg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /examples/django_app/app/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /honeybadger/tests/contrib/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | psutil 2 | six 3 | -------------------------------------------------------------------------------- /examples/aws_lambda/requirements.txt: -------------------------------------------------------------------------------- 1 | honeybadger -------------------------------------------------------------------------------- /honeybadger/version.py: -------------------------------------------------------------------------------- 1 | __version__ = "1.1.0" 2 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.md 2 | include LICENSE 3 | -------------------------------------------------------------------------------- /examples/django_app/honeybadger_example/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /honeybadger/tests/contrib/django_test_app/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /examples/flask/requirements.txt: -------------------------------------------------------------------------------- 1 | Flask 2 | blinker 3 | honeybadger -------------------------------------------------------------------------------- /examples/django_app/requirements.txt: -------------------------------------------------------------------------------- 1 | Django==4.2.* 2 | honeybadger 3 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "python.pythonPath": ".venv/bin/python3" 3 | } -------------------------------------------------------------------------------- /examples/flask-blueprint/requirements.txt: -------------------------------------------------------------------------------- 1 | Flask 2 | blinker 3 | honeybadger -------------------------------------------------------------------------------- /examples/flask-restplus/requirements.txt: -------------------------------------------------------------------------------- 1 | Flask 2 | blinker 3 | honeybadger 4 | flask-restplus= -------------------------------------------------------------------------------- /honeybadger/tests/payload_fixture.txt: -------------------------------------------------------------------------------- 1 | Line 1 2 | Line 2 3 | Line 3 4 | Line 4 5 | Line 5 6 | Line 6 7 | Line 7 8 | Line 8 9 | Line 9 10 | Line 10 11 | -------------------------------------------------------------------------------- /honeybadger/tests/__init__.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings # type: ignore 2 | 3 | # Make sure we do this only once 4 | settings.configure(ALLOWED_HOSTS=["testserver"]) 5 | -------------------------------------------------------------------------------- /examples/django_app/app/apps.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.apps import AppConfig 5 | 6 | 7 | class AppConfig(AppConfig): 8 | name = 'app' 9 | -------------------------------------------------------------------------------- /scripts/install_frameworks.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -ev 3 | 4 | [ ! -z "$DJANGO_VERSION" ] && pip install Django==$DJANGO_VERSION 5 | [ ! -z "$FLASK_VERSION" ] && pip install Flask==$FLASK_VERSION 6 | 7 | echo "OK" 8 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | testpaths = honeybadger/tests 3 | python_files = test_*.py 4 | addopts = --mypy 5 | asyncio_default_fixture_loop_scope = function 6 | # capture live logs (known as "live logging") 7 | # log_cli = True 8 | -------------------------------------------------------------------------------- /examples/django_app/app/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import re_path 2 | 3 | from . import views 4 | 5 | urlpatterns = [ 6 | re_path(r'^greet$', views.greet, name='greet'), 7 | re_path(r'^div$', views.buggy_div, name='div'), 8 | ] 9 | -------------------------------------------------------------------------------- /honeybadger/tests/contrib/django_test_app/views.py: -------------------------------------------------------------------------------- 1 | from django.http import JsonResponse 2 | 3 | 4 | def plain_view(request): 5 | # Test view 6 | return JsonResponse({}) 7 | 8 | 9 | def always_fails(request): 10 | raise ValueError("always fails") 11 | -------------------------------------------------------------------------------- /honeybadger/tests/contrib/django_test_app/middleware.py: -------------------------------------------------------------------------------- 1 | from django.utils.deprecation import MiddlewareMixin 2 | from honeybadger import honeybadger 3 | 4 | 5 | class CustomMiddleware(MiddlewareMixin): 6 | def process_request(self, request): 7 | honeybadger.notify("Custom Middleware Exception") 8 | -------------------------------------------------------------------------------- /examples/aws_lambda/handler.py: -------------------------------------------------------------------------------- 1 | from honeybadger import honeybadger 2 | 3 | honeybadger.configure(api_key='your api key') 4 | 5 | 6 | def lambda_handler(event, context): 7 | """ 8 | A buggy lambda function that tries to perform a zero division 9 | """ 10 | a = 1 11 | b = 0 12 | 13 | return (a/b) 14 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: pip 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | time: "09:00" 8 | timezone: America/Los_Angeles 9 | open-pull-requests-limit: 99 10 | - package-ecosystem: "github-actions" 11 | directory: "/" 12 | schedule: 13 | interval: "weekly" 14 | -------------------------------------------------------------------------------- /honeybadger/types.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | from dataclasses import dataclass 3 | from typing import Optional, Any, Dict 4 | 5 | 6 | class EventsSendStatus(Enum): 7 | OK = "ok" 8 | THROTTLING = "throttling" 9 | ERROR = "error" 10 | 11 | 12 | @dataclass(frozen=True) 13 | class EventsSendResult: 14 | status: EventsSendStatus 15 | reason: Optional[str] = None 16 | 17 | 18 | Notice = Dict[str, Any] 19 | Event = Dict[str, Any] 20 | -------------------------------------------------------------------------------- /honeybadger/tests/contrib/django_test_app/urls.py: -------------------------------------------------------------------------------- 1 | import django 2 | 3 | from . import views 4 | 5 | if django.__version__.startswith("1.11"): 6 | # pylint: disable-next=no-name-in-module 7 | from django.conf.urls import url as path # type: ignore[attr-defined] 8 | else: 9 | from django.urls import path # type: ignore[no-redef] 10 | 11 | 12 | urlpatterns = [ 13 | path("plain_view/", views.plain_view), 14 | path("always_fails/", views.always_fails), 15 | ] 16 | -------------------------------------------------------------------------------- /examples/django_app/honeybadger_example/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for honeybadger_example project. 3 | 4 | It exposes the WSGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/1.11/howto/deployment/wsgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.wsgi import get_wsgi_application 13 | 14 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "honeybadger_example.settings") 15 | 16 | application = get_wsgi_application() 17 | -------------------------------------------------------------------------------- /dev-requirements.txt: -------------------------------------------------------------------------------- 1 | aiounittest 2 | async-asgi-testclient 3 | black 4 | blinker 5 | celery 6 | django 7 | fastapi 8 | flask 9 | httpx 10 | importlib-metadata 11 | jinja2 12 | markupsafe 13 | mock 14 | psutil 15 | pylint 16 | pytest 17 | pytest-cov 18 | pytest-asyncio 19 | six 20 | sqlalchemy 21 | testfixtures 22 | typing-extensions 23 | 24 | # Type checking 25 | pytest-mypy 26 | types-six 27 | types-psutil 28 | types-mock 29 | django-stubs 30 | asgiref 31 | celery-types 32 | 33 | # For Python 3.10+ which removed cgi module 34 | legacy-cgi==2.6.3 35 | -------------------------------------------------------------------------------- /examples/flask-blueprint/app.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from flask import Flask 4 | 5 | from honeybadger.contrib import FlaskHoneybadger 6 | from blueprint import simple_page 7 | 8 | logger = logging.getLogger(__name__) 9 | 10 | app = Flask(__name__) 11 | app.config['HONEYBADGER_ENVIRONMENT'] = 'honeybadger-example' 12 | app.config['HONEYBADGER_API_KEY'] = '' 13 | app.config['HONEYBADGER_PARAMS_FILTERS'] = 'password, secret, credit-card' 14 | FlaskHoneybadger(app, report_exceptions=True) 15 | 16 | app.register_blueprint(simple_page) 17 | -------------------------------------------------------------------------------- /honeybadger/middleware.py: -------------------------------------------------------------------------------- 1 | from .contrib.django import ( 2 | DjangoHoneybadgerMiddleware as RealDjangoHoneybadgerMiddleware, 3 | ) 4 | import warnings 5 | 6 | 7 | class DjangoHoneybadgerMiddleware(RealDjangoHoneybadgerMiddleware): 8 | def __init__(self, *args, **kwargs): 9 | warnings.warn( 10 | "DjangoHoneybadgerMiddleware has moved! Update your imports to import it from honeybadger.contrib", 11 | FutureWarning, 12 | ) 13 | super(DjangoHoneybadgerMiddleware, self).__init__(*args, **kwargs) 14 | -------------------------------------------------------------------------------- /honeybadger/contrib/__init__.py: -------------------------------------------------------------------------------- 1 | from honeybadger.contrib.flask import FlaskHoneybadger 2 | from honeybadger.contrib.django import DjangoHoneybadgerMiddleware 3 | from honeybadger.contrib.aws_lambda import AWSLambdaPlugin 4 | from honeybadger.contrib.logger import HoneybadgerHandler 5 | from honeybadger.contrib.asgi import ASGIHoneybadger 6 | from honeybadger.contrib.celery import CeleryHoneybadger 7 | 8 | __all__ = [ 9 | "FlaskHoneybadger", 10 | "DjangoHoneybadgerMiddleware", 11 | "AWSLambdaPlugin", 12 | "HoneybadgerHandler", 13 | "ASGIHoneybadger", 14 | "CeleryHoneybadger", 15 | ] 16 | -------------------------------------------------------------------------------- /examples/flask-blueprint/blueprint.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from flask import Blueprint, request 4 | 5 | logger = logging.getLogger(__name__) 6 | 7 | 8 | simple_page = Blueprint('simple_page', __name__) 9 | 10 | 11 | def generic_div(a, b): 12 | """Simple function to divide two numbers""" 13 | logger.debug('Called generic_div({}, {})'.format(a, b)) 14 | return a / b 15 | 16 | 17 | @simple_page.route('/') 18 | def index(): 19 | a = int(request.args.get('a')) 20 | b = int(request.args.get('b')) 21 | 22 | logger.info('Dividing two numbers {} {}'.format(a, b)) 23 | return str(generic_div(a, b)) 24 | -------------------------------------------------------------------------------- /examples/flask/README.md: -------------------------------------------------------------------------------- 1 | # Flask example 2 | 3 | ## Installation 4 | 5 | Install the requirements in `requirements.txt`: 6 | 7 | ```bash 8 | virtualenv env 9 | . env/bin/activate 10 | 11 | pip install -r requirements.txt 12 | ``` 13 | 14 | ## Instructions 15 | 16 | - Edit `app.py` and use your test project honeybadger API KEY. 17 | - Run `FLASK_APP=app.py flask run --port 5000` to start the server. 18 | - Visit [http://localhost:5000/?a=1&b=2](http://localhost:5000/?a=1&b=2) to see the app in action. 19 | - Visit [http://localhost:5000/?a=1&b=0](http://localhost:5000/?a=1&b=0) to cause an exception. After a few seconds you should see the exception logged in [Honeybadger's UI](https://app.honeybadger.io). -------------------------------------------------------------------------------- /examples/flask-blueprint/README.md: -------------------------------------------------------------------------------- 1 | # Flask example with blueprint 2 | 3 | ## Installation 4 | 5 | Install the requirements in `requirements.txt`: 6 | 7 | ```bash 8 | virtualenv env 9 | . env/bin/activate 10 | 11 | pip install -r requirements.txt 12 | ``` 13 | 14 | ## Instructions 15 | 16 | - Edit `app.py` and use your test project honeybadger API KEY. 17 | - Run `FLASK_APP=app.py flask run --port 5000` to start the server. 18 | - Visit [http://localhost:5000/?a=1&b=2](http://localhost:5000/?a=1&b=2) to see the app in action. 19 | - Visit [http://localhost:5000/?a=1&b=0](http://localhost:5000/?a=1&b=0) to cause an exception. After a few seconds you should see the exception logged in [Honeybadger's UI](https://app.honeybadger.io). -------------------------------------------------------------------------------- /examples/django_app/app/views.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.http import HttpResponse, JsonResponse 5 | from django.shortcuts import render 6 | 7 | # Create your views here. 8 | 9 | 10 | def greet(request): 11 | return JsonResponse({'message': 'Hello world!'}) 12 | 13 | 14 | def buggy_div(request): 15 | """ 16 | A buggy endpoint to perform division between query parameters a and b. It will fail if b is equal to 0 or 17 | either a or b are not float. 18 | 19 | :param request: request object 20 | :return: 21 | """ 22 | a = float(request.GET.get('a', '0')) 23 | b = float(request.GET.get('b', '0')) 24 | return JsonResponse({'result': a / b}) 25 | -------------------------------------------------------------------------------- /examples/flask-restplus/README.md: -------------------------------------------------------------------------------- 1 | # Flask example with Flask-Restplus 2 | 3 | ## Installation 4 | 5 | Install the requirements in `requirements.txt`: 6 | 7 | ```bash 8 | virtualenv env 9 | . env/bin/activate 10 | 11 | pip install -r requirements.txt 12 | ``` 13 | 14 | ## Instructions 15 | 16 | - Edit `app.py` and use your test project honeybadger API KEY. 17 | - Run `FLASK_APP=app.py flask run --port 5000` to start the server. 18 | - Visit [http://localhost:5000/fraction?a=1&b=2](http://localhost:5000/fraction?a=1&b=2) to see the app in action. 19 | - Visit [http://localhost:5000/fraction?a=1&b=0](http://localhost:5000/fraction?a=1&b=0) to cause an exception. After a few seconds you should see the exception logged in [Honeybadger's UI](https://app.honeybadger.io). -------------------------------------------------------------------------------- /honeybadger/tests/test_middleware.py: -------------------------------------------------------------------------------- 1 | import warnings 2 | from collections import OrderedDict 3 | 4 | from honeybadger.plugins import default_plugin_manager 5 | from .contrib.test_django import DjangoMiddlewareTestCase 6 | from honeybadger.middleware import DjangoHoneybadgerMiddleware 7 | 8 | __all__ = ["MiddlewareTestCase"] 9 | 10 | 11 | class MiddlewareTestCase(DjangoMiddlewareTestCase): 12 | def test_middleware_import_warning(self): 13 | default_plugin_manager._registered = OrderedDict() 14 | with warnings.catch_warnings(record=True) as w: 15 | middleware = DjangoHoneybadgerMiddleware() 16 | assert len(w) == 1 17 | assert issubclass(w[-1].category, FutureWarning) 18 | assert "moved" in str(w[-1].message) 19 | -------------------------------------------------------------------------------- /honeybadger/tests/test_fake_connection.py: -------------------------------------------------------------------------------- 1 | from honeybadger.fake_connection import send_notice 2 | from honeybadger.config import Configuration 3 | from honeybadger.notice import Notice 4 | 5 | from testfixtures import log_capture # type: ignore 6 | import json 7 | 8 | 9 | @log_capture("honeybadger.fake_connection") 10 | def test_send_notice_logging(l): 11 | config = Configuration(api_key="aaa") 12 | notice = Notice( 13 | error_class="TestError", error_message="Test message", config=config 14 | ) 15 | 16 | send_notice(config, notice) 17 | 18 | l.check( 19 | ( 20 | "honeybadger.fake_connection", 21 | "INFO", 22 | "Development mode is enabled; this error will be reported if it occurs after you deploy your app.", 23 | ), 24 | ) 25 | -------------------------------------------------------------------------------- /honeybadger/fake_connection.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from .types import EventsSendResult, EventsSendStatus 3 | 4 | logger = logging.getLogger(__name__) 5 | 6 | 7 | def send_notice(config, notice): 8 | payload = notice.payload 9 | notice_id = payload.get("error", {}).get("token", None) 10 | logger.info( 11 | "Development mode is enabled; this error will be reported if it occurs after you deploy your app." 12 | ) 13 | return notice_id 14 | 15 | 16 | def send_events(config, payload) -> EventsSendResult: 17 | logger.info( 18 | "Development mode is enabled; this event will be reported if it occurs after you deploy your app." 19 | ) 20 | logger.debug( 21 | "[send_events] config used is {} with payload {}".format(config, payload) 22 | ) 23 | return EventsSendResult(EventsSendStatus.OK) 24 | -------------------------------------------------------------------------------- /.github/workflows/check-pr-title.yml: -------------------------------------------------------------------------------- 1 | name: Check PR Title 2 | 3 | on: 4 | pull_request: 5 | branches: [ master ] 6 | types: [opened, edited, synchronize, reopened] 7 | 8 | jobs: 9 | commitlint: 10 | name: Check PR title 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Setup Node.js 14 | uses: actions/setup-node@v4 15 | with: 16 | node-version: '18.x' 17 | 18 | - name: Setup 19 | run: | 20 | npm install -g @commitlint/cli @commitlint/config-conventional 21 | echo "module.exports = {extends: ['@commitlint/config-conventional']}" > commitlint.config.js 22 | 23 | - name: Verify PR title is in the correct format 24 | env: 25 | TITLE: ${{ github.event.pull_request.title }} 26 | run: | 27 | echo $TITLE | npx commitlint -V 28 | -------------------------------------------------------------------------------- /honeybadger/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Use cases: 3 | 4 | >>> from honeybadger import honeybadger 5 | >>> honeybadger.notify() 6 | >>> honeybadger.configure(**kwargs) 7 | >>> honeybadger.context(**kwargs) 8 | """ 9 | 10 | import sys 11 | import signal 12 | import threading 13 | from .core import Honeybadger 14 | from .version import __version__ 15 | 16 | __all__ = ["honeybadger", "__version__"] 17 | 18 | honeybadger = Honeybadger() 19 | honeybadger.wrap_excepthook(sys.excepthook) 20 | 21 | 22 | def _register_signal_handler(): 23 | orig = signal.getsignal(signal.SIGTERM) 24 | 25 | def _on_term(signum, frame): 26 | if callable(orig): 27 | orig(signum, frame) 28 | else: 29 | sys.exit(0) 30 | 31 | signal.signal(signal.SIGTERM, _on_term) 32 | 33 | 34 | if threading.current_thread() is threading.main_thread(): 35 | _register_signal_handler() 36 | -------------------------------------------------------------------------------- /examples/flask/app.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from flask import Flask, request 3 | 4 | from honeybadger.contrib import FlaskHoneybadger 5 | 6 | logger = logging.getLogger(__name__) 7 | 8 | 9 | def generic_div(a, b): 10 | """Simple function to divide two numbers""" 11 | logger.debug('Called generic_div({}, {})'.format(a, b)) 12 | return a / b 13 | 14 | 15 | app = Flask(__name__) 16 | app.config['HONEYBADGER_ENVIRONMENT'] = 'honeybadger-example' 17 | app.config['HONEYBADGER_API_KEY'] = '' 18 | app.config['HONEYBADGER_PARAMS_FILTERS'] = 'password, secret, credit-card' 19 | FlaskHoneybadger(app, report_exceptions=True) 20 | 21 | 22 | @app.route('/', methods=['GET', 'POST', 'PUT']) 23 | def index(): 24 | a = int(request.args.get('a')) 25 | b = int(request.args.get('b')) 26 | 27 | logger.info('Dividing two numbers {} {}'.format(a, b)) 28 | return str(generic_div(a, b)) 29 | -------------------------------------------------------------------------------- /examples/django_app/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | 5 | if __name__ == "__main__": 6 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "honeybadger_example.settings") 7 | try: 8 | from django.core.management import execute_from_command_line 9 | except ImportError: 10 | # The above import may fail for some other reason. Ensure that the 11 | # issue is really that Django is missing to avoid masking other 12 | # exceptions on Python 2. 13 | try: 14 | import django 15 | except ImportError: 16 | raise ImportError( 17 | "Couldn't import Django. Are you sure it's installed and " 18 | "available on your PYTHONPATH environment variable? Did you " 19 | "forget to activate a virtual environment?" 20 | ) 21 | raise 22 | execute_from_command_line(sys.argv) 23 | -------------------------------------------------------------------------------- /examples/flask-restplus/app.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from flask import Flask 4 | 5 | from honeybadger.contrib import FlaskHoneybadger 6 | from flask_restplus import Resource, Api, reqparse 7 | 8 | logger = logging.getLogger(__name__) 9 | 10 | app = Flask(__name__) 11 | app.config['HONEYBADGER_ENVIRONMENT'] = 'honeybadger-example' 12 | app.config['HONEYBADGER_API_KEY'] = '' 13 | app.config['HONEYBADGER_PARAMS_FILTERS'] = 'password, secret, credit-card' 14 | FlaskHoneybadger(app, report_exceptions=True) 15 | 16 | api = Api(app) 17 | 18 | parser = reqparse.RequestParser() 19 | parser.add_argument('a', type=int, help='Numerator') 20 | parser.add_argument('b', type=int, help='Denominator') 21 | 22 | 23 | @api.route('/fraction') 24 | class Fraction(Resource): 25 | def get(self): 26 | args = parser.parse_args() 27 | logger.info('Dividing two numbers {} {}'.format(args.a, args.b)) 28 | return {'result': args.a / args.b} 29 | -------------------------------------------------------------------------------- /examples/django_app/honeybadger_example/urls.py: -------------------------------------------------------------------------------- 1 | """honeybadger_example URL Configuration 2 | 3 | The `urlpatterns` list routes URLs to views. For more information please see: 4 | https://docs.djangoproject.com/en/1.11/topics/http/urls/ 5 | Examples: 6 | Function views 7 | 1. Add an import: from my_app import views 8 | 2. Add a URL to urlpatterns: url(r'^$', views.home, name='home') 9 | Class-based views 10 | 1. Add an import: from other_app.views import Home 11 | 2. Add a URL to urlpatterns: url(r'^$', Home.as_view(), name='home') 12 | Including another URLconf 13 | 1. Import the include() function: from django.urls import re_path, include 14 | 2. Add a URL to urlpatterns: re_path(r'^blog/', include('blog.urls')) 15 | """ 16 | from django.urls import re_path, include 17 | from django.contrib import admin 18 | 19 | urlpatterns = [ 20 | re_path(r'^api/', include('app.urls')), 21 | re_path(r'^admin/', admin.site.urls), 22 | ] 23 | -------------------------------------------------------------------------------- /honeybadger/protocols.py: -------------------------------------------------------------------------------- 1 | from typing import Protocol, Any, Optional, List 2 | from .types import EventsSendResult, Notice, Event 3 | 4 | 5 | class Connection(Protocol): 6 | def send_notice(self, config: Any, notice: Notice) -> Optional[str]: 7 | """ 8 | Send an error notice to Honeybadger. 9 | 10 | Args: 11 | config: The Honeybadger configuration object 12 | payload: The error payload to send 13 | 14 | Returns: 15 | The notice ID if available 16 | """ 17 | ... 18 | 19 | def send_events(self, config: Any, payload: List[Event]) -> EventsSendResult: 20 | """ 21 | Send event batch to Honeybadger. 22 | 23 | Args: 24 | config: The Honeybadger configuration object 25 | payload: The events payload to send 26 | 27 | Returns: 28 | EventsSendResult: The result of the send operation 29 | """ 30 | ... 31 | -------------------------------------------------------------------------------- /.github/workflows/code-quality.yml: -------------------------------------------------------------------------------- 1 | name: Code Quality 2 | 3 | concurrency: 4 | group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} 5 | cancel-in-progress: true 6 | 7 | on: 8 | pull_request: 9 | branches: [ master ] 10 | types: [opened, edited, synchronize, reopened] 11 | 12 | jobs: 13 | pylint: 14 | runs-on: ubuntu-22.04 15 | steps: 16 | - name: Check out ${{ github.ref }} 17 | uses: actions/checkout@v3 18 | 19 | - name: Set up Python 3.11 20 | uses: actions/setup-python@v4 21 | with: 22 | python-version: 3.11 23 | 24 | - name: Install Dependencies 25 | run: | 26 | python -m pip install --upgrade pip 27 | pip install -r dev-requirements.txt 28 | 29 | - name: Run Pylint 30 | run: | 31 | pylint -E ./honeybadger 32 | 33 | - name: Run Black 34 | run: | 35 | black --check --diff ./honeybadger 36 | -------------------------------------------------------------------------------- /examples/unhandled.py: -------------------------------------------------------------------------------- 1 | # Honeybadger for Python 2 | # https://github.com/honeybadger-io/honeybadger-python 3 | # 4 | # This file is an example of how to report unhandled Python exceptions to 5 | # Honeybadger. To run this example: 6 | 7 | # $ pip install honeybadger 8 | # $ HONEYBADGER_API_KEY=your-api-key python unhandled.py 9 | from __future__ import print_function 10 | from honeybadger import honeybadger 11 | 12 | # Uncomment the following line or use the HONEYBADGER_API_KEY environment 13 | # variable to configure the API key for your Honeybadger project: 14 | # honeybadger.configure(api_key='your api key') 15 | 16 | import logging 17 | logging.getLogger('honeybadger').addHandler(logging.StreamHandler()) 18 | 19 | 20 | def method_two(): 21 | mydict = dict(a=1) 22 | print(mydict['b']) 23 | 24 | 25 | def method_one(): 26 | method_two() 27 | 28 | 29 | if __name__ == '__main__': 30 | honeybadger.set_context(user_email="user@example.com") 31 | method_one() 32 | -------------------------------------------------------------------------------- /examples/try.py: -------------------------------------------------------------------------------- 1 | # Honeybadger for Python 2 | # https://github.com/honeybadger-io/honeybadger-python 3 | # 4 | # This file is an example of how to catch an exception in Python and report it 5 | # to Honeybadger without re-raising. To run this example: 6 | 7 | # $ pip install honeybadger 8 | # $ HONEYBADGER_API_KEY=your-api-key python try.py 9 | from __future__ import print_function 10 | from honeybadger import honeybadger 11 | 12 | # Uncomment the following line or use the HONEYBADGER_API_KEY environment 13 | # variable to configure the API key for your Honeybadger project: 14 | # honeybadger.configure(api_key='your api key') 15 | 16 | import logging 17 | logging.getLogger('honeybadger').addHandler(logging.StreamHandler()) 18 | 19 | 20 | def method_two(): 21 | mydict = dict(a=1) 22 | try: 23 | print(mydict['b']) 24 | except KeyError as exc: 25 | honeybadger.notify(exc, context={'foo': 'bar'}) 26 | 27 | 28 | def method_one(): 29 | method_two() 30 | 31 | 32 | if __name__ == '__main__': 33 | honeybadger.set_context(user_email="user@example.com") 34 | method_one() 35 | -------------------------------------------------------------------------------- /honeybadger/contrib/fastapi.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from fastapi.routing import APIRoute 4 | from fastapi import exceptions 5 | from starlette.requests import Request 6 | from typing import Callable 7 | 8 | from honeybadger import honeybadger 9 | from honeybadger.contrib import asgi 10 | 11 | 12 | class HoneybadgerRoute(APIRoute): 13 | def get_route_handler(self) -> Callable: 14 | original_route_handler = super().get_route_handler() 15 | 16 | async def custom_route_handler(request: Request): 17 | try: 18 | return await original_route_handler(request) 19 | except exceptions.HTTPException as exc: 20 | raise exc from None 21 | except Exception as exc: 22 | body = await request.body() 23 | scope = dict(request) 24 | scope["body"] = body 25 | honeybadger.notify(exception=exc, context=asgi._as_context(scope)) 26 | raise exc from None 27 | finally: 28 | honeybadger.reset_context() 29 | 30 | return custom_route_handler 31 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import re 2 | import sys 3 | import os 4 | from codecs import open 5 | from setuptools import setup 6 | 7 | 8 | def get_version(): 9 | with open("honeybadger/version.py", encoding="utf-8") as f: 10 | return re.search(r'^__version__ = [\'"]([^\'"]+)[\'"]', f.read(), re.M).group(1) 11 | 12 | 13 | setup( 14 | name="honeybadger", 15 | version=get_version(), 16 | description="Send Python and Django errors to Honeybadger", 17 | url="https://github.com/honeybadger-io/honeybadger-python", 18 | author="Dave Sullivan", 19 | author_email="dave@davesullivan.ca", 20 | license="MIT", 21 | packages=["honeybadger", "honeybadger.contrib"], 22 | classifiers=[ 23 | "Development Status :: 4 - Beta", 24 | "Intended Audience :: Developers", 25 | "License :: OSI Approved :: MIT License", 26 | "Programming Language :: Python :: 3.4", 27 | "Programming Language :: Python :: 3.5", 28 | "Programming Language :: Python :: 3.6", 29 | "Programming Language :: Python :: 3.7", 30 | "Topic :: System :: Monitoring", 31 | ], 32 | install_requires=["psutil", "six"], 33 | ) 34 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Honeybadger Industries LLC 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /examples/aws_lambda/README.md: -------------------------------------------------------------------------------- 1 | # Example AWS Lambda function for Honeybadger app 2 | 3 | This is an example AWS Lambda function. 4 | 5 | # Pre-requisites 6 | 7 | Install AWS-CLI 8 | https://docs.aws.amazon.com/cli/latest/userguide/install-cliv2-mac.html#cliv2-mac-prereq 9 | 10 | ## Deploying 11 | 12 | Install honeybadger in a package directory with pip's --target option 13 | `pip3 install --target ./package ../../` 14 | 15 | `Navigate to package directory 16 | `cd package` 17 | 18 | Create a deployment package with the installed libraries at the root. 19 | `zip -r ../test-honeybadger-lambda.zip .` 20 | 21 | Navigate back to the my-function directory. 22 | `cd ..` 23 | 24 | Add function code files to the root of your deployment package. 25 | `zip -g test-honeybadger-lambda.zip handler.py` 26 | 27 | use aws-cli update function code command to upload the binary .zip file to Lambda and update the function code. 28 | ```aws_lambda % aws lambda create-function\ 29 | --function-name HoneyBadgerLambda \ 30 | --zip-file fileb://test-honeybadger-lambda.zip\ 31 | --role *your role arn here*\ 32 | --region us-east-1 --runtime python3.6 --handler handler.lambda_handler 33 | ``` 34 | -------------------------------------------------------------------------------- /honeybadger/contrib/db.py: -------------------------------------------------------------------------------- 1 | import time 2 | import re 3 | from honeybadger import honeybadger 4 | from honeybadger.utils import get_duration 5 | 6 | 7 | class DBHoneybadger: 8 | @staticmethod 9 | def django_execute(orig_exec): 10 | def wrapper(self, sql, params=None): 11 | start = time.time() 12 | try: 13 | return orig_exec(self, sql, params) 14 | finally: 15 | DBHoneybadger.execute(sql, start, params) 16 | 17 | return wrapper 18 | 19 | @staticmethod 20 | def execute(sql, start, params=None): 21 | db_config = honeybadger.config.insights_config.db 22 | if db_config.disabled: 23 | return 24 | 25 | q = db_config.exclude_queries 26 | if q and any( 27 | (pattern.search(sql) if hasattr(pattern, "search") else pattern in sql) 28 | for pattern in q 29 | ): 30 | return 31 | 32 | data = { 33 | "query": sql, 34 | "duration": get_duration(start), 35 | } 36 | 37 | if params and db_config.include_params: 38 | data["params"] = params 39 | 40 | honeybadger.event("db.query", data) 41 | -------------------------------------------------------------------------------- /honeybadger/contrib/logger.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from honeybadger.core import Honeybadger 3 | 4 | DEFAULT_IGNORED_KEYS = { 5 | "process", 6 | "thread", 7 | "levelno", 8 | "pathname", 9 | "module", 10 | "filename", 11 | "funcName", 12 | "asctime", 13 | "msecs", 14 | "processName", 15 | "relativeCreated", 16 | "threadName", 17 | "stack_info", 18 | "exc_info", 19 | "exc_text", 20 | "args", 21 | "msg", 22 | "message", 23 | } 24 | 25 | 26 | class HoneybadgerHandler(logging.Handler): 27 | 28 | def __init__(self, api_key): 29 | 30 | self.honeybadger = Honeybadger() 31 | self.honeybadger.configure(api_key=api_key) 32 | 33 | logging.Handler.__init__(self) 34 | 35 | def _get_context(self, record): 36 | return { 37 | k: v for (k, v) in record.__dict__.items() if k not in DEFAULT_IGNORED_KEYS 38 | } 39 | 40 | def emit(self, record): 41 | 42 | try: 43 | self.honeybadger.notify( 44 | error_class="%s Log" % record.levelname, 45 | error_message=record.getMessage(), 46 | context=self._get_context(record), 47 | ) 48 | 49 | except Exception: 50 | self.handleError(record) 51 | -------------------------------------------------------------------------------- /honeybadger/tests/contrib/test_fastapi.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | import aiounittest 3 | import mock 4 | from honeybadger.contrib.fastapi import HoneybadgerRoute 5 | from fastapi import FastAPI 6 | from fastapi.testclient import TestClient 7 | 8 | 9 | class FastAPITestCase(unittest.TestCase): 10 | def setUp(self): 11 | app = FastAPI() 12 | app.router.route_class = HoneybadgerRoute 13 | 14 | @app.get("/ok") 15 | def ok_route(): 16 | return "ok" 17 | 18 | @app.get("/ko") 19 | def ko_route(): 20 | return 1 / 0 21 | 22 | self.client = TestClient(app, raise_server_exceptions=False) 23 | 24 | @mock.patch("honeybadger.contrib.fastapi.honeybadger") 25 | def test_should_not_notify_on_ok_route(self, hb): 26 | response = self.client.get("/ok") 27 | self.assertEqual(response.status_code, 200) 28 | hb.notify.assert_not_called() 29 | 30 | @mock.patch("honeybadger.contrib.fastapi.honeybadger") 31 | def test_should_notify_on_ko_route(self, hb): 32 | response = self.client.get("/ko") 33 | self.assertEqual(response.status_code, 500) 34 | hb.notify.assert_called_once() 35 | self.assertEqual( 36 | type(hb.notify.call_args.kwargs["exception"]), ZeroDivisionError 37 | ) 38 | -------------------------------------------------------------------------------- /examples/django_app/README.md: -------------------------------------------------------------------------------- 1 | # Example Django Honeybadger app 2 | 3 | This is an example Django application. 4 | 5 | ## Installing 6 | 7 | Run the following commands to create a virtual environment and install required dependencies. 8 | 9 | ```bash 10 | virtualenv --no-site-packages env 11 | . env/bin/activate 12 | pip install -r requirements.txt 13 | ``` 14 | 15 | ## Starting the server 16 | 17 | You 'll need to configure honeybadger environment variables. If you don't, 18 | exceptions won't be logged at Honeybadger's console. 19 | 20 | ```bash 21 | export HONEYBADGER_API_KEY= 22 | export HONEYBADGER_ENVIRONMENT=test_environment 23 | ``` 24 | 25 | Then run 26 | ```bash 27 | python manage.py runserver 28 | ``` 29 | 30 | ## Testing some errors 31 | 32 | The example contains a [view](app/views.py) that reads two request arguments (`a` and `b`) and returns the result of `a / b`. 33 | You can use this endpoint to generate some errors. You can try the following examples: 34 | 35 | - [http://localhost:8000/api/div?a=1&b=0](http://localhost:8000/api/div?a=1&b=0). This will cause a division by zero. 36 | - [http://localhost:8000/api/div?a=1&b=foo](http://localhost:8000/api/div?a=1&b=foo). Parameter `b` is not valid, as it's a string and not a number. 37 | 38 | If everything has been configured properly, you'll see the results in Honeybadger console. -------------------------------------------------------------------------------- /examples/fastapi/app.py: -------------------------------------------------------------------------------- 1 | from fastapi import FastAPI, HTTPException, APIRouter 2 | from honeybadger import honeybadger, contrib 3 | import pydantic 4 | 5 | honeybadger.configure(api_key='') 6 | 7 | app = FastAPI(title="Honeybadger - FastAPI with Middleware.") 8 | app.add_middleware(contrib.ASGIHoneybadger, params_filters=["client"]) 9 | 10 | 11 | @app.get("/raise_some_error", tags=["Notify"]) 12 | def raise_some_error(a: str = "foo"): 13 | """Raises an error.""" 14 | raise Exception(f"SomeError Occurred (a = {a})") 15 | 16 | 17 | class DivideRequest(pydantic.BaseModel): 18 | a: int 19 | b: int = 0 20 | 21 | 22 | @app.post("/divide", response_model=float, tags=["Notify"]) 23 | def divide(req: DivideRequest): 24 | """Divides `a` by `b`.""" 25 | return req.a / req.b 26 | 27 | 28 | @app.post("/raise_status_code", tags=["Don't Notify"]) 29 | def raise_status_code(status_code: int = 404, detail: str = "Forced 404."): 30 | """This exception is raised on purpose, so will not be notified.""" 31 | raise HTTPException(status_code=404, detail=detail) 32 | 33 | 34 | some_router = APIRouter() 35 | 36 | 37 | @some_router.get("/some_router/endpoint", tags=["Notify"]) 38 | def some_router_endpoint(): 39 | """Try raising an error from a router.""" 40 | raise Exception("Exception Raised by some router endpoint.") 41 | 42 | 43 | app.include_router(some_router) 44 | -------------------------------------------------------------------------------- /examples/fastapi/custom_route.py: -------------------------------------------------------------------------------- 1 | from fastapi import FastAPI, HTTPException, APIRouter 2 | from honeybadger import honeybadger, contrib 3 | import pydantic 4 | 5 | honeybadger.configure(api_key='') 6 | 7 | app = FastAPI(title="Honeybadger - FastAPI with Custom Route.") 8 | app.router.route_class = contrib.HoneybadgerRoute 9 | 10 | 11 | @app.get("/raise_some_error", tags=["Notify"]) 12 | def raise_some_error(a: str = "foo"): 13 | """Raises an error.""" 14 | raise Exception(f"SomeError Occurred (a = {a})") 15 | 16 | 17 | class DivideRequest(pydantic.BaseModel): 18 | a: int 19 | b: int = 0 20 | 21 | 22 | @app.post("/divide", response_model=float, tags=["Notify"]) 23 | def divide(req: DivideRequest): 24 | """Divides `a` by `b`.""" 25 | return req.a / req.b 26 | 27 | 28 | @app.post("/raise_status_code", tags=["Don't Notify"]) 29 | def raise_status_code(status_code: int = 404, detail: str = "Forced 404."): 30 | """This exception is raised on purpose, so will not be notified.""" 31 | raise HTTPException(status_code=404, detail=detail) 32 | 33 | 34 | some_router = APIRouter(route_class=contrib.HoneybadgerRoute) 35 | 36 | 37 | @some_router.get("/some_router/endpoint", tags=["Notify"]) 38 | def some_router_endpoint(): 39 | """Try raising an error from a router.""" 40 | raise Exception("Exception Raised by some router endpoint.") 41 | 42 | 43 | app.include_router(some_router) 44 | -------------------------------------------------------------------------------- /honeybadger/tests/test_connection.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logging 3 | import pytest 4 | from six import b 5 | from .utils import mock_urlopen 6 | 7 | from honeybadger.connection import send_notice 8 | from honeybadger.config import Configuration 9 | from honeybadger.notice import Notice 10 | import uuid 11 | 12 | 13 | def test_connection_success(): 14 | api_key = "badgerbadgermushroom" 15 | config = Configuration(api_key=api_key) 16 | notice = Notice( 17 | error_class="TestError", error_message="Test message", config=config 18 | ) 19 | 20 | def test_request(request_object): 21 | assert request_object.get_header("X-api-key") == api_key 22 | assert request_object.get_full_url() == "{}/v1/notices/".format(config.endpoint) 23 | assert request_object.data == b(json.dumps(notice.payload)) 24 | 25 | with mock_urlopen(test_request) as request_mock: 26 | send_notice(config, notice) 27 | 28 | 29 | def test_connection_returns_notice_id(): 30 | api_key = "badgerbadgermushroom" 31 | config = Configuration(api_key=api_key) 32 | notice = Notice( 33 | error_class="TestError", error_message="Test message", config=config 34 | ) 35 | 36 | def test_payload(request_object): 37 | assert request_object.data == b(json.dumps(notice.payload)) 38 | 39 | with mock_urlopen(test_payload) as request_mock: 40 | assert send_notice(config, notice) == notice.payload.get("error", {}).get( 41 | "token", None 42 | ) 43 | 44 | 45 | # TODO: figure out how to test logging output 46 | -------------------------------------------------------------------------------- /honeybadger/context_store.py: -------------------------------------------------------------------------------- 1 | from contextvars import ContextVar 2 | from contextlib import contextmanager 3 | from typing import Any, Dict, Optional 4 | 5 | 6 | class ContextStore: 7 | def __init__(self, name: str): 8 | self._ctx: ContextVar[Optional[Dict[str, Any]]] = ContextVar(name, default=None) 9 | 10 | def get(self) -> Dict[str, Any]: 11 | data = self._ctx.get() 12 | return {} if data is None else data.copy() 13 | 14 | def clear(self) -> None: 15 | self._ctx.set({}) 16 | 17 | def update(self, ctx: Optional[Dict[str, Any]] = None, **kwargs: Any) -> None: 18 | """ 19 | Merge into the current context. Accepts either: 20 | - update({'foo': 'bar'}) 21 | - update(foo='bar', baz=123) 22 | - or both: update({'foo': 'bar'}, baz=123) 23 | """ 24 | to_merge: Dict[str, Any] = {} 25 | if ctx: 26 | to_merge.update(ctx) 27 | to_merge.update(kwargs) 28 | 29 | new = self.get() 30 | new.update(to_merge) 31 | self._ctx.set(new) 32 | 33 | @contextmanager 34 | def override(self, ctx: Optional[Dict[str, Any]] = None, **kwargs: Any): 35 | """ 36 | Temporarily merge these into context for the duration of the with-block. 37 | """ 38 | to_merge: Dict[str, Any] = {} 39 | if ctx: 40 | to_merge.update(ctx) 41 | to_merge.update(kwargs) 42 | 43 | token = self._ctx.set({**self.get(), **to_merge}) 44 | try: 45 | yield 46 | finally: 47 | self._ctx.reset(token) 48 | -------------------------------------------------------------------------------- /honeybadger/tests/utils.py: -------------------------------------------------------------------------------- 1 | from contextlib import contextmanager 2 | from mock import patch 3 | from mock import DEFAULT 4 | import inspect 5 | import six 6 | import time 7 | from functools import wraps 8 | from threading import Event 9 | from honeybadger import honeybadger 10 | from honeybadger.config import Configuration 11 | 12 | 13 | @contextmanager 14 | def mock_urlopen(func, status=201): 15 | mock_called_event = Event() 16 | 17 | def mock_was_called(*args, **kwargs): 18 | mock_called_event.set() 19 | return DEFAULT 20 | 21 | with patch( 22 | "six.moves.urllib.request.urlopen", side_effect=mock_was_called 23 | ) as request_mock: 24 | yield request_mock 25 | mock_called_event.wait(0.5) 26 | ((request_object,), mock_kwargs) = request_mock.call_args 27 | func(request_object) 28 | 29 | 30 | def with_config(config): 31 | """ 32 | Decorator to set honeybadger.config for a test, and restore it after. 33 | Usage: 34 | @with_config({"a": "b"}) 35 | def test_...(): 36 | ... 37 | """ 38 | 39 | def decorator(fn): 40 | if inspect.iscoroutinefunction(fn): 41 | 42 | @wraps(fn) 43 | async def wrapper(*args, **kwargs): 44 | honeybadger.configure(**config) 45 | try: 46 | return await fn(*args, **kwargs) 47 | finally: 48 | honeybadger.config = Configuration() 49 | 50 | else: 51 | 52 | @wraps(fn) 53 | def wrapper(*args, **kwargs): 54 | honeybadger.configure(**config) 55 | try: 56 | return fn(*args, **kwargs) 57 | finally: 58 | honeybadger.config = Configuration() 59 | 60 | return wrapper 61 | 62 | return decorator 63 | -------------------------------------------------------------------------------- /.github/workflows/pypi-publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish new version on PyPi 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | branches: 7 | - master 8 | 9 | permissions: 10 | contents: write 11 | issues: write 12 | pull-requests: write 13 | repository-projects: read # this is required by release-please-action to set the auto-release tags 14 | 15 | # Note for release-please-action: 16 | # The action will set the auto-release tags for the release PR. If these tags do not already exist, the action will fail to create them. 17 | # Therefore, make sure that the tags are created in the repository before running the release workflow: `autorelease: pending`, `autorelease: tagged` 18 | 19 | jobs: 20 | test: 21 | uses: honeybadger-io/honeybadger-python/.github/workflows/python.yml@master 22 | 23 | publish: 24 | needs: [ test ] 25 | runs-on: ubuntu-22.04 26 | steps: 27 | - uses: googleapis/release-please-action@v4 28 | id: release 29 | with: 30 | token: ${{ secrets.GITHUB_TOKEN }} 31 | release-type: python 32 | 33 | # The logic below handles Pypi publishing. 34 | - name: Checkout 35 | uses: actions/checkout@v5 36 | if: ${{ steps.release.outputs.release_created }} 37 | 38 | - name: Setup python 39 | if: ${{ steps.release.outputs.release_created }} 40 | uses: actions/setup-python@v4 41 | with: 42 | python-version: '3.9' 43 | 44 | - name: Build for python 3 45 | if: ${{ steps.release.outputs.release_created }} 46 | run: | 47 | pip install --upgrade twine wheel 48 | python setup.py bdist_wheel 49 | ls dist 50 | 51 | - name: Upload 52 | if: ${{ steps.release.outputs.release_created }} 53 | env: 54 | TWINE_USERNAME: __token__ 55 | TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }} 56 | run: twine upload dist/* 57 | -------------------------------------------------------------------------------- /.github/workflows/python.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | concurrency: 4 | group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} 5 | cancel-in-progress: true 6 | 7 | on: 8 | workflow_call: 9 | pull_request: 10 | branches: [ master ] 11 | 12 | jobs: 13 | build: 14 | 15 | runs-on: ubuntu-22.04 16 | strategy: 17 | max-parallel: 4 18 | matrix: 19 | python-version: ['3.8', '3.9', '3.10', '3.11', '3.12'] 20 | env: 21 | - DJANGO_VERSION=4.2.20 22 | - DJANGO_VERSION=5.0.14 23 | - DJANGO_VERSION=5.1.8 24 | - DJANGO_VERSION=5.2 25 | - FLASK_VERSION=1.1.4 26 | - FLASK_VERSION=2.3.3 27 | - FLASK_VERSION=3.0.3 28 | - FLASK_VERSION=3.1.0 29 | exclude: 30 | - python-version: '3.8' 31 | env: DJANGO_VERSION=5.0.14 32 | - python-version: '3.8' 33 | env: DJANGO_VERSION=5.1.8 34 | - python-version: '3.8' 35 | env: DJANGO_VERSION=5.2 36 | - python-version: '3.9' 37 | env: DJANGO_VERSION=5.0.14 38 | - python-version: '3.9' 39 | env: DJANGO_VERSION=5.1.8 40 | - python-version: '3.9' 41 | env: DJANGO_VERSION=5.2 42 | - python-version: '3.8' 43 | env: FLASK_VERSION=3.1.0 44 | 45 | steps: 46 | 47 | - name: Check out ${{ github.ref }} 48 | uses: actions/checkout@v3 49 | 50 | - name: Set up Python ${{ matrix.python-version }} 51 | uses: actions/setup-python@v4 52 | with: 53 | python-version: ${{ matrix.python-version }} 54 | 55 | - name: Install Dependencies 56 | run: | 57 | export ${{ matrix.env }} 58 | python -m pip install --upgrade pip 59 | ./scripts/install_frameworks.sh 60 | pip install -r dev-requirements.txt 61 | 62 | - name: Run Tests 63 | run: | 64 | export ${{ matrix.env }} 65 | python -m pytest --tb=short --disable-warnings -v 66 | -------------------------------------------------------------------------------- /honeybadger/tests/contrib/test_db.py: -------------------------------------------------------------------------------- 1 | import time 2 | import re 3 | from functools import wraps 4 | from unittest.mock import patch, MagicMock 5 | from honeybadger.tests.utils import with_config 6 | 7 | import pytest 8 | 9 | from honeybadger import honeybadger 10 | from honeybadger.config import Configuration 11 | from honeybadger.contrib.db import DBHoneybadger 12 | 13 | 14 | @patch("honeybadger.honeybadger.event") 15 | def test_execute_sends_event_when_enabled(mock_event): 16 | DBHoneybadger.execute("SELECT 1", start=0) 17 | mock_event.assert_called_once() 18 | args, kwargs = mock_event.call_args 19 | assert args[0] == "db.query" 20 | assert args[1]["query"] == "SELECT 1" 21 | assert args[1]["duration"] > 0 22 | assert "params" not in args[1] 23 | 24 | 25 | @with_config({"insights_config": {"db": {"disabled": True}}}) 26 | @patch("honeybadger.honeybadger.event") 27 | def test_execute_does_not_send_event_when_disabled(mock_event): 28 | DBHoneybadger.execute("SELECT 1", start=0) 29 | mock_event.assert_not_called() 30 | 31 | 32 | @with_config({"insights_config": {"db": {"include_params": True}}}) 33 | @patch("honeybadger.honeybadger.event") 34 | def test_execute_includes_params(mock_event): 35 | params = (123, "abc") 36 | DBHoneybadger.execute("SELECT x FROM t WHERE a=%s AND b=%s", start=0, params=params) 37 | args, kwargs = mock_event.call_args 38 | assert args[1]["params"] == params 39 | 40 | 41 | @patch("honeybadger.honeybadger.event") 42 | def test_execute_does_not_include_params_when_not_configured(mock_event): 43 | params = (1, 2) 44 | DBHoneybadger.execute("SELECT 1", start=0, params=params) 45 | args, kwargs = mock_event.call_args 46 | assert "params" not in args[1] 47 | 48 | 49 | @with_config( 50 | {"insights_config": {"db": {"exclude_queries": [re.compile(r"SELECT 1")]}}} 51 | ) 52 | @patch("honeybadger.honeybadger.event") 53 | def test_execute_excludes_queries_for_regexes(mock_event): 54 | DBHoneybadger.execute("SELECT 1 abc", start=0) 55 | mock_event.assert_not_called() 56 | 57 | 58 | @with_config({"insights_config": {"db": {"exclude_queries": ["PRAGMA"]}}}) 59 | @patch("honeybadger.honeybadger.event") 60 | def test_execute_excludes_queries_for_strings(mock_event): 61 | DBHoneybadger.execute("PRAGMA (*)", start=0) 62 | mock_event.assert_not_called() 63 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | 27 | # PyInstaller 28 | # Usually these files are written by a python script from a template 29 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 30 | *.manifest 31 | *.spec 32 | 33 | # Installer logs 34 | pip-log.txt 35 | pip-delete-this-directory.txt 36 | 37 | # Unit test / coverage reports 38 | htmlcov/ 39 | .tox/ 40 | .coverage 41 | .coverage.* 42 | .cache 43 | nosetests.xml 44 | coverage.xml 45 | *,cover 46 | .hypothesis/ 47 | 48 | # Translations 49 | *.mo 50 | *.pot 51 | 52 | # Django stuff: 53 | *.log 54 | 55 | # Sphinx documentation 56 | docs/_build/ 57 | 58 | # PyBuilder 59 | target/ 60 | 61 | #Ipython Notebook 62 | .ipynb_checkpoints 63 | 64 | # Created by https://www.gitignore.io/api/pycharm 65 | 66 | ### PyCharm ### 67 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and Webstorm 68 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 69 | 70 | # User-specific stuff: 71 | .idea/**/workspace.xml 72 | .idea/**/tasks.xml 73 | .idea/dictionaries 74 | 75 | # Sensitive or high-churn files: 76 | .idea/**/dataSources/ 77 | .idea/**/dataSources.ids 78 | .idea/**/dataSources.xml 79 | .idea/**/dataSources.local.xml 80 | .idea/**/sqlDataSources.xml 81 | .idea/**/dynamic.xml 82 | .idea/**/uiDesigner.xml 83 | 84 | # Gradle: 85 | .idea/**/gradle.xml 86 | .idea/**/libraries 87 | 88 | # CMake 89 | cmake-build-debug/ 90 | 91 | # Mongo Explorer plugin: 92 | .idea/**/mongoSettings.xml 93 | 94 | ## File-based project format: 95 | *.iws 96 | 97 | ## Plugin-specific files: 98 | 99 | # IntelliJ 100 | /out/ 101 | 102 | # mpeltonen/sbt-idea plugin 103 | .idea_modules/ 104 | 105 | # JIRA plugin 106 | atlassian-ide-plugin.xml 107 | 108 | # Cursive Clojure plugin 109 | .idea/replstate.xml 110 | 111 | # Ruby plugin and RubyMine 112 | /.rakeTasks 113 | 114 | # Crashlytics plugin (for Android Studio and IntelliJ) 115 | com_crashlytics_export_strings.xml 116 | crashlytics.properties 117 | crashlytics-build.properties 118 | fabric.properties 119 | 120 | ### PyCharm Patch ### 121 | # Comment Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-215987721 122 | 123 | # *.iml 124 | # modules.xml 125 | # .idea/misc.xml 126 | # *.ipr 127 | 128 | .idea 129 | 130 | # Sonarlint plugin 131 | .idea/sonarlint 132 | 133 | 134 | .tool-versions 135 | .direnv 136 | 137 | -------------------------------------------------------------------------------- /honeybadger/utils.py: -------------------------------------------------------------------------------- 1 | import json 2 | import time 3 | import re 4 | 5 | 6 | class StringReprJSONEncoder(json.JSONEncoder): 7 | def default(self, o): 8 | try: 9 | return repr(o) 10 | except: 11 | return "[unserializable]" 12 | 13 | 14 | # List of allowed CGI environment variables 15 | CGI_ALLOWLIST = [ 16 | "AUTH_TYPE", 17 | "CONTENT_LENGTH", 18 | "CONTENT_TYPE", 19 | "GATEWAY_INTERFACE", 20 | "HOST", 21 | "HTTPS", 22 | "REMOTE_ADDR", 23 | "REMOTE_HOST", 24 | "REMOTE_IDENT", 25 | "REMOTE_USER", 26 | "REQUEST_METHOD", 27 | "SERVER_NAME", 28 | "SERVER_PORT", 29 | "SERVER_PROTOCOL", 30 | "SERVER_SOFTWARE", 31 | ] 32 | 33 | 34 | def filter_env_vars(data): 35 | """Filter environment variables to only include HTTP_ prefixed vars and allowed CGI vars.""" 36 | if type(data) != dict: 37 | return data 38 | 39 | filtered_data = {} 40 | for key, value in data.items(): 41 | normalized_key = key.upper().replace( 42 | "-", "_" 43 | ) # Either CONTENT_TYPE or Content-Type is valid 44 | if normalized_key.startswith("HTTP_") or normalized_key in CGI_ALLOWLIST: 45 | filtered_data[key] = value 46 | return filtered_data 47 | 48 | 49 | def filter_dict(data, filter_keys, remove_keys=False): 50 | if type(data) != dict: 51 | return data 52 | 53 | keys = list(data.keys()) 54 | for key in keys: 55 | # While tuples are considered valid dictionary keys, 56 | # they are not json serializable 57 | # so we remove them from the dictionary 58 | if type(key) == tuple: 59 | data.pop(key) 60 | continue 61 | 62 | if type(data[key]) == dict: 63 | data[key] = filter_dict(data[key], filter_keys) 64 | 65 | if key in filter_keys: 66 | if remove_keys: 67 | data.pop(key) 68 | else: 69 | data[key] = "[FILTERED]" 70 | 71 | return data 72 | 73 | 74 | PREFIX = "HONEYBADGER_" 75 | 76 | 77 | def extract_honeybadger_config(kwargs): 78 | return { 79 | key[len(PREFIX) :].lower(): value 80 | for key, value in kwargs.items() 81 | if key.startswith(PREFIX) 82 | } 83 | 84 | 85 | def get_duration(start_time): 86 | """Get the duration in milliseconds since start_time.""" 87 | if start_time is None: 88 | return None 89 | 90 | return round((time.time() - start_time) * 1000, 4) 91 | 92 | 93 | def sanitize_request_id(request_id): 94 | """Sanitize a Request ID by keeping only alphanumeric characters and hyphens.""" 95 | if not request_id: 96 | return None 97 | 98 | sanitized = re.sub(r"[^a-zA-Z0-9-]", "", request_id.strip())[:255] 99 | 100 | return sanitized or None 101 | -------------------------------------------------------------------------------- /honeybadger/notice.py: -------------------------------------------------------------------------------- 1 | from functools import cached_property 2 | from .payload import create_payload 3 | 4 | 5 | class Notice(object): 6 | def __init__(self, *args, **kwargs): 7 | self.exception = kwargs.get("exception", None) 8 | self.error_class = kwargs.get("error_class", None) 9 | self.error_message = kwargs.get("error_message", None) 10 | self.exc_traceback = kwargs.get("exc_traceback", None) 11 | self.fingerprint = kwargs.get("fingerprint", None) 12 | self.config = kwargs.get("config", None) 13 | self.context = kwargs.get("context", {}) 14 | self.request_id = kwargs.get("request_id", None) 15 | self.tags = self._construct_tags(kwargs.get("tags", [])) 16 | 17 | self._process_exception() 18 | 19 | def _process_exception(self): 20 | if self.exception and self.error_message: 21 | self.context["error_message"] = self.error_message 22 | 23 | if self.exception is None: 24 | self.exception = { 25 | "error_class": self.error_class, 26 | "error_message": self.error_message, 27 | } 28 | 29 | @cached_property 30 | def payload(self): 31 | return create_payload( 32 | self.exception, 33 | self.exc_traceback, 34 | fingerprint=self.fingerprint, 35 | context=self.context, 36 | tags=self.tags, 37 | config=self.config, 38 | correlation_context=self._correlation_context(), 39 | ) 40 | 41 | def excluded_exception(self): 42 | if self.config.excluded_exceptions: 43 | if ( 44 | self.exception 45 | and self.exception.__class__.__name__ in self.config.excluded_exceptions 46 | ): 47 | return True 48 | elif ( 49 | self.error_class and self.error_class in self.config.excluded_exceptions 50 | ): 51 | return True 52 | return False 53 | 54 | def _correlation_context(self): 55 | if self.request_id: 56 | return {"request_id": self.request_id} 57 | return None 58 | 59 | def _construct_tags(self, tags): 60 | """ 61 | Accepts either: 62 | - a single string (possibly comma-separated) 63 | - a list of strings (each possibly comma-separated) 64 | and returns a flat list of stripped tags. 65 | """ 66 | raw = [] 67 | if isinstance(tags, str): 68 | raw = [tags] 69 | elif isinstance(tags, (list, tuple)): 70 | raw = tags 71 | out = [] 72 | for item in raw: 73 | if not isinstance(item, str): 74 | continue 75 | for part in item.split(","): 76 | t = part.strip() 77 | if t: 78 | out.append(t) 79 | return out 80 | -------------------------------------------------------------------------------- /honeybadger/tests/test_utils.py: -------------------------------------------------------------------------------- 1 | from honeybadger.utils import filter_dict, filter_env_vars, sanitize_request_id 2 | 3 | 4 | def test_filter_dict(): 5 | data = {"foo": "bar", "bar": "baz"} 6 | expected = {"foo": "[FILTERED]", "bar": "baz"} 7 | filter_keys = ["foo"] 8 | assert filter_dict(data, filter_keys) == expected 9 | 10 | 11 | def test_filter_dict_with_nested_dict(): 12 | data = {"foo": "bar", "bar": "baz", "nested": {"password": "helloworld"}} 13 | expected = {"foo": "bar", "bar": "baz", "nested": {"password": "[FILTERED]"}} 14 | filter_keys = ["password"] 15 | assert filter_dict(data, filter_keys) == expected 16 | 17 | 18 | def test_ignores_dict_with_tuple_key(): 19 | data = {("foo", "bar"): "baz", "key": "value"} 20 | expected = {"key": "value"} 21 | filter_keys = ["foo"] 22 | assert filter_dict(data, filter_keys) == expected 23 | 24 | 25 | def test_filter_env_vars_with_http_prefix(): 26 | data = { 27 | "HTTP_HOST": "example.com", 28 | "HTTP_USER_AGENT": "Mozilla", 29 | "PATH": "/usr/bin", 30 | "TERM": "xterm", 31 | } 32 | expected = {"HTTP_HOST": "example.com", "HTTP_USER_AGENT": "Mozilla"} 33 | assert filter_env_vars(data) == expected 34 | 35 | 36 | def test_filter_env_vars_with_cgi_allowlist(): 37 | data = { 38 | "CONTENT_LENGTH": "256", 39 | "REMOTE_ADDR": "127.0.0.1", 40 | "SERVER_NAME": "localhost", 41 | "DATABASE_URL": "postgres://localhost", 42 | "AWS_SECRET_KEY": "secret123", 43 | } 44 | expected = { 45 | "CONTENT_LENGTH": "256", 46 | "REMOTE_ADDR": "127.0.0.1", 47 | "SERVER_NAME": "localhost", 48 | } 49 | assert filter_env_vars(data) == expected 50 | 51 | 52 | def test_filter_env_vars_with_mixed_vars(): 53 | data = { 54 | "HTTP_HOST": "example.com", 55 | "CONTENT_LENGTH": "256", 56 | "AWS_SECRET_KEY": "secret123", 57 | "DATABASE_URL": "postgres://localhost", 58 | "PATH": "/usr/bin", 59 | } 60 | expected = {"HTTP_HOST": "example.com", "CONTENT_LENGTH": "256"} 61 | assert filter_env_vars(data) == expected 62 | 63 | 64 | def test_filter_env_vars_with_non_dict(): 65 | assert filter_env_vars(None) is None 66 | assert filter_env_vars([]) == [] 67 | assert filter_env_vars("string") == "string" 68 | 69 | 70 | def test_filter_env_vars_empty_dict(): 71 | assert filter_env_vars({}) == {} 72 | 73 | 74 | def test_sanitize_request_id(): 75 | assert sanitize_request_id("abc123-def456") == "abc123-def456" 76 | assert sanitize_request_id("abc_123@def#456") == "abc123def456" 77 | assert sanitize_request_id("a" * 300) == "a" * 255 78 | assert sanitize_request_id(" abc123 ") == "abc123" 79 | assert sanitize_request_id("@#$%^&*()") is None 80 | assert sanitize_request_id(None) is None 81 | assert sanitize_request_id("") is None 82 | assert sanitize_request_id(" ") is None 83 | -------------------------------------------------------------------------------- /honeybadger/plugins.py: -------------------------------------------------------------------------------- 1 | from abc import ABCMeta, abstractmethod 2 | from collections import OrderedDict 3 | from logging import getLogger 4 | 5 | from six import add_metaclass, iteritems 6 | 7 | logger = getLogger(__name__) 8 | 9 | 10 | @add_metaclass(ABCMeta) 11 | class Plugin(object): 12 | """ 13 | Base class for plugins. A plugin is used to add functionality related to frameworks. 14 | """ 15 | 16 | def __init__(self, name): 17 | """ 18 | Initialize plugin. 19 | :param name: the name of the plugin. 20 | """ 21 | self.name = name 22 | 23 | def supports(self, config, context): 24 | """ 25 | Whether this plugin supports generating payload for the current configuration, request and context. 26 | :param exception: current exception. 27 | :param config: honeybadger configuration. 28 | :param context: current honeybadger context. 29 | :return: True if plugin can generate payload for current exception, False else. 30 | """ 31 | return False 32 | 33 | @abstractmethod 34 | def generate_payload(self, config, context): 35 | """ 36 | Return additional payload for given exception. May be used by actual plugin implementations to gather additional 37 | information. 38 | :param config: honeybadger configuration 39 | :param context: context gathered so far to send to honeybadger. 40 | :return: a dictionary with the generated payload. 41 | """ 42 | pass 43 | 44 | 45 | class PluginManager(object): 46 | """ 47 | Manages lifecycle of plugins. 48 | """ 49 | 50 | def __init__(self): 51 | self._registered = OrderedDict() 52 | 53 | def register(self, plugin): 54 | """ 55 | Register the given plugin. Registration order is kept. 56 | :param plugin: the plugin to register. 57 | """ 58 | if plugin.name not in self._registered: 59 | logger.info("Registering plugin %s" % plugin.name) 60 | self._registered[plugin.name] = plugin 61 | else: 62 | logger.warning("Plugin %s already registered" % plugin.name) 63 | 64 | def generate_payload(self, default_payload, config=None, context=None): 65 | """ 66 | Generate payload by iterating over registered plugins. Merges . 67 | :param context: current context. 68 | :param config: honeybadger configuration. 69 | :return: a dict with the generated payload. 70 | """ 71 | for name, plugin in iteritems(self._registered): 72 | if plugin.supports(config, context): 73 | logger.debug("Returning payload from plugin %s" % name) 74 | 75 | default_payload = plugin.generate_payload( 76 | default_payload, config, context 77 | ) 78 | else: 79 | logger.debug("No active plugin to generate payload") 80 | 81 | return default_payload 82 | 83 | 84 | # Global plugin manager 85 | default_plugin_manager = PluginManager() 86 | -------------------------------------------------------------------------------- /honeybadger/connection.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import json 3 | import threading 4 | 5 | from urllib.error import HTTPError, URLError 6 | from six.moves.urllib import request 7 | from six import b 8 | 9 | from .utils import StringReprJSONEncoder 10 | from .types import EventsSendResult, EventsSendStatus 11 | 12 | 13 | logger = logging.getLogger(__name__) 14 | 15 | 16 | def _make_http_request(path, config, payload): 17 | if not config.api_key: 18 | logger.error( 19 | "Honeybadger API key missing from configuration: cannot report errors." 20 | ) 21 | return 22 | 23 | request_object = request.Request( 24 | url=config.endpoint + path, 25 | data=b(json.dumps(payload, cls=StringReprJSONEncoder)), 26 | ) 27 | request_object.add_header("X-Api-Key", config.api_key) 28 | request_object.add_header("Content-Type", "application/json") 29 | request_object.add_header("Accept", "application/json") 30 | 31 | def send_request(): 32 | response = request.urlopen(request_object) 33 | 34 | status = response.getcode() 35 | if status != 201: 36 | logger.error( 37 | "Received error response [{}] from Honeybadger API.".format(status) 38 | ) 39 | 40 | if config.force_sync: 41 | send_request() 42 | else: 43 | t = threading.Thread(target=send_request) 44 | t.start() 45 | 46 | 47 | def send_notice(config, notice): 48 | payload = notice.payload 49 | notice_id = payload.get("error", {}).get("token", None) 50 | path = "/v1/notices/" 51 | _make_http_request(path, config, payload) 52 | return notice_id 53 | 54 | 55 | def send_events(config, payload) -> EventsSendResult: 56 | """ 57 | Send events synchronously to Honeybadger. This is designed to be used with 58 | the EventsWorker. 59 | 60 | Returns: 61 | - "ok" if status == 201 62 | - "throttling" if status == 429 63 | - "error" for any 400–599 or network failure 64 | """ 65 | if not config.api_key: 66 | return EventsSendResult(EventsSendStatus.ERROR, "missing api key") 67 | 68 | jsonl = "\n".join(json.dumps(it, cls=StringReprJSONEncoder) for it in payload) 69 | 70 | req = request.Request( 71 | url=f"{config.endpoint}/v1/events/", 72 | data=jsonl.encode("utf-8"), 73 | ) 74 | req.add_header("X-Api-Key", config.api_key) 75 | req.add_header("Content-Type", "application/x-ndjson") 76 | req.add_header("Accept", "application/json") 77 | 78 | try: 79 | resp = request.urlopen(req) 80 | status = resp.getcode() 81 | except HTTPError as e: 82 | status = e.code 83 | except URLError as e: 84 | return EventsSendResult(EventsSendStatus.ERROR, str(e.reason)) 85 | 86 | if status == 201 or status == 200: 87 | logger.debug( 88 | "Sent {} events to Honeybadger, got HTTP {}".format(len(payload), status) 89 | ) 90 | return EventsSendResult(EventsSendStatus.OK) 91 | if status == 429: 92 | return EventsSendResult(EventsSendStatus.THROTTLING) 93 | return EventsSendResult(EventsSendStatus.ERROR, f"got HTTP {status}") 94 | -------------------------------------------------------------------------------- /examples/django_app/honeybadger_example/settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for honeybadger_example project. 3 | 4 | Generated by 'django-admin startproject' using Django 1.11.10. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/1.11/topics/settings/ 8 | 9 | For the full list of settings and their values, see 10 | https://docs.djangoproject.com/en/1.11/ref/settings/ 11 | """ 12 | 13 | import os 14 | 15 | # Build paths inside the project like this: os.path.join(BASE_DIR, ...) 16 | BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 17 | 18 | 19 | # Quick-start development settings - unsuitable for production 20 | # See https://docs.djangoproject.com/en/1.11/howto/deployment/checklist/ 21 | 22 | # SECURITY WARNING: keep the secret key used in production secret! 23 | SECRET_KEY = '&q0a-xvwu-b^x-@p)n$t7#=w&69tv*@s(qp7n0!^b_$=1(8z53' 24 | 25 | # SECURITY WARNING: don't run with debug turned on in production! 26 | DEBUG = True 27 | HONEYBADGER = { 28 | 'FORCE_REPORT_DATA': True, 29 | } 30 | 31 | ALLOWED_HOSTS = [] 32 | 33 | 34 | # Application definition 35 | 36 | INSTALLED_APPS = [ 37 | 'django.contrib.admin', 38 | 'django.contrib.auth', 39 | 'django.contrib.contenttypes', 40 | 'django.contrib.sessions', 41 | 'django.contrib.messages', 42 | 'django.contrib.staticfiles', 43 | ] 44 | 45 | MIDDLEWARE = [ 46 | # Honeybadger middleware goes to the top 47 | 'honeybadger.contrib.DjangoHoneybadgerMiddleware', 48 | 'django.middleware.security.SecurityMiddleware', 49 | 'django.contrib.sessions.middleware.SessionMiddleware', 50 | 'django.middleware.common.CommonMiddleware', 51 | 'django.middleware.csrf.CsrfViewMiddleware', 52 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 53 | 'django.contrib.messages.middleware.MessageMiddleware', 54 | 'django.middleware.clickjacking.XFrameOptionsMiddleware', 55 | ] 56 | 57 | ROOT_URLCONF = 'honeybadger_example.urls' 58 | 59 | TEMPLATES = [ 60 | { 61 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 62 | 'DIRS': [], 63 | 'APP_DIRS': True, 64 | 'OPTIONS': { 65 | 'context_processors': [ 66 | 'django.template.context_processors.debug', 67 | 'django.template.context_processors.request', 68 | 'django.contrib.auth.context_processors.auth', 69 | 'django.contrib.messages.context_processors.messages', 70 | ], 71 | }, 72 | }, 73 | ] 74 | 75 | WSGI_APPLICATION = 'honeybadger_example.wsgi.application' 76 | 77 | 78 | # Database 79 | # https://docs.djangoproject.com/en/1.11/ref/settings/#databases 80 | 81 | DATABASES = { 82 | 'default': { 83 | 'ENGINE': 'django.db.backends.sqlite3', 84 | 'NAME': os.path.join(BASE_DIR, 'db.sqlite3'), 85 | } 86 | } 87 | 88 | 89 | # Password validation 90 | # https://docs.djangoproject.com/en/1.11/ref/settings/#auth-password-validators 91 | 92 | AUTH_PASSWORD_VALIDATORS = [ 93 | { 94 | 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', 95 | }, 96 | { 97 | 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', 98 | }, 99 | { 100 | 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', 101 | }, 102 | { 103 | 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', 104 | }, 105 | ] 106 | 107 | 108 | # Internationalization 109 | # https://docs.djangoproject.com/en/1.11/topics/i18n/ 110 | 111 | LANGUAGE_CODE = 'en-us' 112 | 113 | TIME_ZONE = 'UTC' 114 | 115 | USE_I18N = True 116 | 117 | USE_L10N = True 118 | 119 | USE_TZ = True 120 | 121 | 122 | # Static files (CSS, JavaScript, Images) 123 | # https://docs.djangoproject.com/en/1.11/howto/static-files/ 124 | 125 | STATIC_URL = '/static/' 126 | -------------------------------------------------------------------------------- /honeybadger/tests/test_notice.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import threading 3 | 4 | from honeybadger.config import Configuration 5 | from honeybadger.notice import Notice 6 | 7 | 8 | def test_notice_initialization_with_exception(): 9 | # Test with exception 10 | exception = Exception("Test exception") 11 | notice = Notice(exception=exception) 12 | assert notice.exception == exception 13 | assert notice.error_class is None 14 | assert notice.error_message is None 15 | 16 | 17 | def test_notice_initialization_with_error_class_and_error_message(): 18 | # Test with error_class and error_message 19 | notice = Notice(error_class="TestError", error_message="Test message") 20 | assert notice.exception == { 21 | "error_class": "TestError", 22 | "error_message": "Test message", 23 | } 24 | assert notice.error_class == "TestError" 25 | assert notice.error_message == "Test message" 26 | 27 | 28 | def test_notice_initialization_with_exception_and_error_message(): 29 | # Test with exception and error_message 30 | exception = Exception("Test exception") 31 | notice = Notice(exception=exception, error_message="Test message") 32 | assert notice.exception == exception 33 | assert notice.context["error_message"] == "Test message" 34 | 35 | 36 | def test_notice_excluded_exception(): 37 | config = Configuration(excluded_exceptions=["TestError", "Exception"]) 38 | 39 | # Test with excluded exception 40 | notice = Notice( 41 | error_class="TestError", error_message="Test message", config=config 42 | ) 43 | assert notice.excluded_exception() is True 44 | 45 | # Test with non-excluded exception 46 | notice = Notice( 47 | error_class="NonExcludedError", error_message="Test message", config=config 48 | ) 49 | assert notice.excluded_exception() is False 50 | 51 | # Test with exception 52 | notice = Notice(exception=Exception("Test exception"), config=config) 53 | assert notice.excluded_exception() is True 54 | 55 | 56 | def test_notice_payload(): 57 | config = Configuration() 58 | 59 | # Test with exception 60 | notice = Notice(exception=Exception("Test exception"), config=config) 61 | payload = notice.payload 62 | assert payload["error"]["class"] == "Exception" 63 | assert payload["error"]["message"] == "Test exception" 64 | 65 | # Test with error_class and error_message 66 | notice = Notice( 67 | error_class="TestError", error_message="Test message", config=config 68 | ) 69 | payload = notice.payload 70 | assert payload["error"]["class"] == "TestError" 71 | assert payload["error"]["message"] == "Test message" 72 | 73 | 74 | def test_notice_with_tags(): 75 | config = Configuration() 76 | 77 | notice = Notice( 78 | error_class="TestError", 79 | error_message="Test message", 80 | tags="tag1, tag2", 81 | config=config, 82 | ) 83 | payload = notice.payload 84 | assert "tag1" in payload["error"]["tags"] 85 | assert "tag2" in payload["error"]["tags"] 86 | 87 | 88 | def test_notice_with_multiple_tags(): 89 | config = Configuration() 90 | 91 | notice = Notice( 92 | error_class="TestError", 93 | error_message="Test message", 94 | tags=["tag1, tag2", "tag3"], 95 | config=config, 96 | ) 97 | payload = notice.payload 98 | assert "tag1" in payload["error"]["tags"] 99 | assert "tag2" in payload["error"]["tags"] 100 | assert "tag3" in payload["error"]["tags"] 101 | 102 | 103 | def test_notice_with_request_id(): 104 | config = Configuration() 105 | 106 | notice = Notice( 107 | error_class="TestError", 108 | error_message="Test message", 109 | request_id="12345", 110 | config=config, 111 | ) 112 | payload = notice.payload 113 | assert payload["correlation_context"]["request_id"] == "12345" 114 | -------------------------------------------------------------------------------- /honeybadger/tests/contrib/test_asgi.py: -------------------------------------------------------------------------------- 1 | import pprint 2 | import unittest 3 | from async_asgi_testclient import TestClient # type: ignore 4 | import aiounittest 5 | import mock 6 | 7 | from honeybadger import honeybadger 8 | from honeybadger import contrib 9 | from honeybadger.config import Configuration 10 | from honeybadger.tests.utils import with_config 11 | 12 | 13 | class SomeError(Exception): 14 | pass 15 | 16 | 17 | def asgi_app(): 18 | """Example ASGI App.""" 19 | 20 | async def app(scope, receive, send): 21 | if "error" in scope["path"]: 22 | raise SomeError("Some Error.") 23 | headers = [(b"content-type", b"text/html")] 24 | body = f"
{pprint.PrettyPrinter(indent=2, width=256).pformat(scope)}
".encode( 25 | "utf-8" 26 | ) 27 | await send({"type": "http.response.start", "status": 200, "headers": headers}) 28 | await send({"type": "http.response.body", "body": body}) 29 | 30 | return app 31 | 32 | 33 | class ASGIPluginTestCase(unittest.TestCase): 34 | def setUp(self): 35 | self.client = TestClient(contrib.ASGIHoneybadger(asgi_app(), api_key="abcd")) 36 | 37 | @mock.patch("honeybadger.contrib.asgi.honeybadger") 38 | def test_should_support_asgi(self, hb): 39 | asgi_context = {"asgi": {"version": "3.0"}} 40 | non_asgi_context = {} 41 | self.assertTrue(self.client.application.supports(hb.config, asgi_context)) 42 | self.assertFalse(self.client.application.supports(hb.config, non_asgi_context)) 43 | 44 | @aiounittest.async_test 45 | @mock.patch("honeybadger.contrib.asgi.honeybadger") 46 | async def test_should_notify_exception(self, hb): 47 | with self.assertRaises(SomeError): 48 | await self.client.get("/error") 49 | hb.notify.assert_called_once() 50 | self.assertEqual(type(hb.notify.call_args.kwargs["exception"]), SomeError) 51 | 52 | @aiounittest.async_test 53 | @mock.patch("honeybadger.contrib.asgi.honeybadger") 54 | async def test_should_not_notify_exception(self, hb): 55 | response = await self.client.get("/") 56 | hb.notify.assert_not_called() 57 | 58 | 59 | class ASGIEventPayloadTestCase(unittest.TestCase): 60 | @aiounittest.async_test 61 | @with_config({"insights_enabled": True}) 62 | @mock.patch("honeybadger.contrib.asgi.honeybadger.event") 63 | async def test_success_event_payload(self, event): 64 | app = TestClient( 65 | contrib.ASGIHoneybadger(asgi_app(), api_key="abcd", insights_enabled=True) 66 | ) 67 | # even if there’s a query, url stays just the path 68 | await app.get("/hello?x=1") 69 | event.assert_called_once() 70 | name, payload = event.call_args.args 71 | 72 | self.assertEqual(name, "asgi.request") 73 | self.assertEqual(payload["method"], "GET") 74 | self.assertEqual(payload["path"], "/hello") 75 | self.assertEqual(payload["status"], 200) 76 | self.assertIsInstance(payload["duration"], float) 77 | 78 | @aiounittest.async_test 79 | @with_config( 80 | {"insights_enabled": True, "insights_config": {"asgi": {"disabled": True}}} 81 | ) 82 | @mock.patch("honeybadger.contrib.asgi.honeybadger.event") 83 | async def test_disabled_by_insights_config(self, event): 84 | app = TestClient(contrib.ASGIHoneybadger(asgi_app(), api_key="abcd")) 85 | await app.get("/hello?x=1") 86 | event.assert_not_called() 87 | 88 | @aiounittest.async_test 89 | @with_config( 90 | { 91 | "insights_enabled": True, 92 | "insights_config": {"asgi": {"include_params": True}}, 93 | } 94 | ) 95 | @mock.patch("honeybadger.contrib.asgi.honeybadger.event") 96 | async def test_disable_insights(self, event): 97 | app = TestClient( 98 | contrib.ASGIHoneybadger(asgi_app(), api_key="abcd", insights_enabled=True) 99 | ) 100 | await app.get("/hello?x=1&password=secret&y=2&y=3") 101 | event.assert_called_once() 102 | name, payload = event.call_args.args 103 | self.assertEqual(payload["params"], {"x": "1", "y": ["2", "3"]}) 104 | -------------------------------------------------------------------------------- /honeybadger/tests/test_plugins.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from mock import Mock 4 | from honeybadger.plugins import PluginManager 5 | 6 | 7 | class PluginManagerTestCase(unittest.TestCase): 8 | def setUp(self): 9 | self.manager = PluginManager() 10 | self.plugin1 = Mock() 11 | self.plugin1.name = "plugin1" 12 | self.plugin2 = Mock() 13 | self.plugin2.name = "plugin2" 14 | self.default_payload = {} 15 | 16 | def test_register(self): 17 | self.manager.register(self.plugin1) 18 | self.manager.register(self.plugin2) 19 | self.manager.register(self.plugin1) 20 | 21 | self.assertListEqual( 22 | list(self.manager._registered.keys()), ["plugin1", "plugin2"] 23 | ) 24 | self.assertListEqual( 25 | list(self.manager._registered.values()), [self.plugin1, self.plugin2] 26 | ) 27 | 28 | def test_generate_payload_first_plugin(self): 29 | self.manager.register(self.plugin1) 30 | self.manager.register(self.plugin2) 31 | context = {"test": "context"} 32 | 33 | # Given both plugin support providing payload 34 | self.plugin1.supports.return_value = True 35 | self.plugin1.generate_payload.return_value = {"name": "plugin1"} 36 | self.plugin2.supports.return_value = True 37 | self.plugin2.generate_payload.return_value = {"name": "plugin2"} 38 | 39 | payload = self.manager.generate_payload(self.default_payload, context=context) 40 | # Expect order to be preferred and use value from all registered plugins. 41 | # Plugin2 is given a forced return value for the sake of testing, 42 | # hence it overrides payload from plugin1 43 | self.assertDictEqual({"name": "plugin2"}, payload) 44 | self.plugin1.supports.assert_called_once_with(None, context) 45 | self.plugin1.generate_payload.assert_called_once_with( 46 | self.default_payload, None, context 47 | ) 48 | 49 | # Assert both plugins got called once 50 | self.assertEqual(1, self.plugin2.supports.call_count) 51 | self.assertEqual(1, self.plugin2.generate_payload.call_count) 52 | 53 | def test_generate_payload_second_plugin(self): 54 | self.manager.register(self.plugin1) 55 | self.manager.register(self.plugin2) 56 | context = {"test": "context"} 57 | 58 | # Given only 2nd registered plugin supports providing payload 59 | self.plugin1.supports.return_value = False 60 | self.plugin2.supports.return_value = True 61 | self.plugin2.generate_payload.return_value = {"name": "plugin2"} 62 | 63 | payload = self.manager.generate_payload( 64 | default_payload=self.default_payload, context=context 65 | ) 66 | # Expect order to be preferred and use value from second plugin 67 | self.assertDictEqual({"name": "plugin2"}, payload) 68 | self.plugin1.supports.assert_called_once_with(None, context) 69 | self.assertEqual(0, self.plugin1.generate_payload.call_count) 70 | self.plugin2.supports.assert_called_once_with(None, context) 71 | self.plugin2.generate_payload.assert_called_once_with( 72 | self.default_payload, None, context 73 | ) 74 | 75 | def test_generate_payload_none(self): 76 | self.manager.register(self.plugin1) 77 | self.manager.register(self.plugin2) 78 | context = {"test": "context"} 79 | default_payload = {"request": {"context": context}} 80 | 81 | # Given no registered plugin supports providing payload 82 | self.plugin1.supports.return_value = False 83 | self.plugin2.supports.return_value = False 84 | 85 | payload = self.manager.generate_payload( 86 | default_payload=default_payload, context=context 87 | ) 88 | print(payload) 89 | # Expect order to be preferred and use input context value 90 | self.assertDictEqual({"request": {"context": context}}, payload) 91 | self.plugin1.supports.assert_called_once_with(None, context) 92 | self.assertEqual(0, self.plugin1.generate_payload.call_count) 93 | self.plugin2.supports.assert_called_once_with(None, context) 94 | self.assertEqual(0, self.plugin2.generate_payload.call_count) 95 | -------------------------------------------------------------------------------- /honeybadger/tests/test_config.py: -------------------------------------------------------------------------------- 1 | from __future__ import print_function 2 | 3 | import os 4 | import pytest 5 | import logging 6 | 7 | from honeybadger.config import Configuration 8 | 9 | 10 | def test_12factor_overrides_defaults(): 11 | os.environ["HONEYBADGER_ENVIRONMENT"] = "staging" 12 | c = Configuration() 13 | assert c.environment == "staging" 14 | 15 | 16 | def test_args_overrides_defaults(): 17 | c = Configuration(environment="staging") 18 | assert c.environment == "staging" 19 | 20 | 21 | def test_args_overrides_12factor(): 22 | os.environ["HONEYBADGER_ENVIRONMENT"] = "test" 23 | c = Configuration(environment="staging") 24 | assert c.environment == "staging" 25 | 26 | 27 | def test_config_var_types_are_accurate(): 28 | os.environ["HONEYBADGER_PARAMS_FILTERS"] = "password,password_confirm,user_email" 29 | c = Configuration() 30 | assert c.params_filters == ["password", "password_confirm", "user_email"] 31 | 32 | 33 | def test_config_bool_types_are_accurate(): 34 | os.environ["HONEYBADGER_FORCE_REPORT_DATA"] = "1" 35 | c = Configuration() 36 | del os.environ["HONEYBADGER_FORCE_REPORT_DATA"] 37 | assert c.force_report_data == True 38 | 39 | 40 | def test_can_only_set_valid_options(caplog): 41 | with caplog.at_level(logging.WARNING): 42 | try: 43 | Configuration(foo="bar") 44 | except AttributeError: 45 | pass 46 | assert any( 47 | "Unknown Configuration option" in msg for msg in caplog.text.splitlines() 48 | ) 49 | 50 | 51 | def test_is_okay_with_unknown_env_var(): 52 | os.environ["HONEYBADGER_FOO"] = "bar" 53 | try: 54 | Configuration() 55 | except Exception: 56 | pytest.fail("This should fail silently.") 57 | 58 | 59 | def test_nested_dataclass_raises_for_invalid_key(caplog): 60 | c = Configuration(insights_config={}) 61 | with caplog.at_level(logging.WARNING): 62 | c.set_config_from_dict({"insights_config": {"db": {"bogus": True}}}) 63 | assert any("Unknown DBConfig option" in msg for msg in caplog.text.splitlines()) 64 | 65 | 66 | def test_set_config_from_dict_raises_for_unknown_key(caplog): 67 | c = Configuration() 68 | with caplog.at_level(logging.WARNING): 69 | c.set_config_from_dict({"does_not_exist": 123}) 70 | assert any( 71 | "Unknown Configuration option" in msg for msg in caplog.text.splitlines() 72 | ) 73 | 74 | 75 | def test_valid_dev_environments(): 76 | valid_dev_environments = ["development", "dev", "test"] 77 | 78 | assert len(Configuration.DEVELOPMENT_ENVIRONMENTS) == len(valid_dev_environments) 79 | assert set(Configuration.DEVELOPMENT_ENVIRONMENTS) == set(valid_dev_environments) 80 | 81 | 82 | def test_override_development_environments(): 83 | custom_dev_envs = ["local", "staging"] 84 | c = Configuration(development_environments=custom_dev_envs) 85 | assert c.development_environments == custom_dev_envs 86 | 87 | 88 | def test_is_dev_true_for_dev_environments(): 89 | for env in Configuration.DEVELOPMENT_ENVIRONMENTS: 90 | c = Configuration(environment=env) 91 | assert c.is_dev() 92 | 93 | 94 | def test_is_dev_false_for_non_dev_environments(): 95 | c = Configuration(environment="production") 96 | assert c.is_dev() == False 97 | 98 | 99 | def test_is_dev_true_for_custom_dev_environments(): 100 | custom_dev_envs = ["local", "staging"] 101 | c = Configuration(environment="local", development_environments=custom_dev_envs) 102 | assert c.is_dev() == True 103 | 104 | c = Configuration(environment="staging", development_environments=custom_dev_envs) 105 | assert c.is_dev() == True 106 | 107 | 108 | def test_is_dev_false_for_custom_non_dev_environments(): 109 | custom_dev_envs = ["local", "staging"] 110 | c = Configuration( 111 | environment="production", development_environments=custom_dev_envs 112 | ) 113 | assert c.is_dev() == False 114 | 115 | c = Configuration(environment="qa", development_environments=custom_dev_envs) 116 | assert c.is_dev() == False 117 | 118 | 119 | def test_force_report_data_not_active(): 120 | c = Configuration() 121 | assert c.force_report_data == False 122 | 123 | 124 | def test_configure_before_notify(): 125 | def before_notify_callback(notice): 126 | return notice 127 | 128 | c = Configuration(before_notify=before_notify_callback) 129 | assert c.before_notify == before_notify_callback 130 | 131 | 132 | def test_configure_nested_insights_config(): 133 | c = Configuration(insights_config={"db": {"disabled": True}}) 134 | assert c.insights_config.db.disabled == True 135 | 136 | 137 | def test_configure_throws_for_invalid_insights_config(caplog): 138 | with caplog.at_level(logging.WARNING): 139 | Configuration(insights_config={"foo": "bar"}) 140 | assert any( 141 | "Unknown InsightsConfig option" in msg for msg in caplog.text.splitlines() 142 | ) 143 | 144 | 145 | def test_configure_merges_insights_config(): 146 | c = Configuration(api_key="test", insights_config={}) 147 | 148 | c.set_config_from_dict({"insights_config": {"db": {"include_params": True}}}) 149 | assert hasattr(c.insights_config, "db") 150 | assert c.insights_config.db.include_params is True 151 | 152 | c.set_config_from_dict({"insights_config": {"celery": {"disabled": True}}}) 153 | assert hasattr(c.insights_config, "celery") 154 | assert c.insights_config.celery.disabled is True 155 | 156 | assert c.insights_config.db.include_params is True 157 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | All notable changes to this project will be documented in this file. See [Keep a 3 | CHANGELOG](http://keepachangelog.com/) for how to update this file. This project 4 | adheres to [Semantic Versioning](http://semver.org/). 5 | 6 | ## [1.1.0] - 2025-10-07 7 | - Add Insights automatic instrumentation (#215) 8 | 9 | ## [1.0.3] - 2025-07-21 10 | - Fix: Register signal handler only in main thread 11 | 12 | ## [1.0.2] - 2025-07-04 13 | - Fix: Removes deprecated usage of datetime.utcnow 14 | 15 | ## [1.0.1] - 2025-06-09 16 | - Fix: Always create error dictionary if exception is not provided (#222) 17 | 18 | ## [1.0.0] - 2025-06-05 19 | - Allow overriding development environments (#218) 20 | 21 | ## [0.23.1] - 2025-05-23 22 | - Fix: removes raising an exception in the `Notice` class. 23 | - Fix: `fake_connection` returns same type as `connection` (#212) 24 | 25 | ## [0.23.0] - 2025-05-12 26 | - Add `before_notify` hook to allow modification of notice before sending (#203) 27 | - Allow tags to be passed explicitly (#202) 28 | - Add `EventsWorker` for batching Insights events (#201) 29 | - Breaking: raises an exception if neither an `exception` nor an `error_class` is passed to `honeybadger.notify()` 30 | 31 | ## [0.22.1] - 2025-04-22 32 | - Fix: Prevent infinite loop in exception cause chains by capping traversal 33 | 34 | ## [0.22.0] - 2025-03-31 35 | - Fix: `event_type` is not a required key for honeybadger.event() 36 | - Docs: Update README to include honeybadger.event() usage 37 | 38 | ## [0.21] - 2025-02-11 39 | - Fix: Merge (rather than replace) context from Celery task into report data (#189) 40 | 41 | ## [0.20.3] - 2025-01-23 42 | - Fix: Only send a restricted set of environment variables 43 | 44 | ## [0.20.2] - 2024-08-04 45 | - Django: Fix for automatically capturing user id and user name when available 46 | 47 | ## [0.20.1] - 2024-06-14 48 | - Fix: Resolve "can't pickle '_io.TextIOWrapper' object" error (#173) 49 | 50 | ## [0.20.0] - 2024-06-01 51 | - Feat: honeybadger.event() for sending events to Honeybadger Insights 52 | 53 | ## [0.19.1] - 2024-04-07 54 | 55 | ## [0.19.1] - 2024-04-07 56 | - Fix: Ignore tuple keys when JSON-encoding dictionaries 57 | 58 | ## [0.19.0] - 2024-01-13 59 | - AWS Lambda: Support for Python 3.9+ 60 | 61 | ## [0.18.0] - 2024-01-12 62 | - Flask: Support for Flask v3 63 | 64 | ## [0.17.0] - 2023-07-27 65 | - Django: Automatically capture user id and user name when available 66 | 67 | ## [0.16.0] - 2023-07-12 68 | - Django example actually generates an alert 69 | - Target Django v4 in CI but not with Python v3.7 70 | - Added Django v3.2 and v4.2 in version matrix for tests 71 | 72 | ## [0.15.2] - 2023-03-31 73 | - honeybadger.notify() now returns notice uuid (#139) 74 | 75 | ## [0.15.1] - 2023-02-15 76 | 77 | ## [0.15.0] - 2023-02-01 78 | 79 | ## [0.14.1] - 2022-12-14 80 | 81 | ## [0.14.0] - 2022-12-10 82 | ### Added 83 | - Add Celery integration. (#124) 84 | 85 | ## [0.13.0] - 2022-11-11 86 | 87 | ## [0.12.0] - 2022-10-04 88 | 89 | ## [0.11.0] - 2022-09-23 90 | ### Fixed 91 | - Make fingerprint a top-level function parameter (#115) 92 | 93 | ## [0.10.0] - 2022-09-09 94 | ### Added 95 | - Allow passing fingerprint in `notify()` (#115) 96 | 97 | ## [0.9.0] - 2022-08-18 98 | ### Added 99 | - Recursively add nested exceptions to exception 'causes' 100 | 101 | ## [0.8.0] - 2021-11-01 102 | ### Added 103 | - Added `excluded_exceptions` config option (#98) 104 | 105 | ## [0.7.1] - 2021-09-13 106 | ### Fixed 107 | - Fixed post-python3.7 lambda bug: (#95, #97) 108 | > Lambda function not wrapped by honeybadger: module 'main' has no attribute 'handle_http_request' 109 | 110 | ## [0.7.0] - 2021-08-16 111 | ### Added 112 | - Added log handler (#82) 113 | 114 | ### Fixed 115 | - Allow 'None' as argument for context (#92) 116 | 117 | ## [0.6.0] - 2021-05-24 118 | ### Added 119 | - Add new ASGI middleware plugin (FastAPI, Starlette, Uvicorn). (#84) 120 | - Add FastAPI custom route. (#84) 121 | 122 | ### Fixed 123 | - Fix deprecated `logger.warn` call. (#84) 124 | 125 | ## [0.5.0] - 2021-03-17 126 | 127 | ### Added 128 | - Add `CSRF_COOKIE` to default filter_params (#44) 129 | - Add `HTTP_COOKIE` to payload for flask & django (#44) 130 | - Filter meta (cgi_data) attributes for flask & django (#43) 131 | - Add `force_sync` config option (#60) 132 | - Add additional server payload for AWS lambda environment (#60) 133 | 134 | ## [0.4.2] - 2021-02-04 135 | ### Fixed 136 | - Fix wrong getattr statement (#65) 137 | 138 | ## [0.4.1] - 2021-01-19 139 | ### Fixed 140 | - Make psutil optional for use in serverless environments (#63, @kstevens715) 141 | 142 | ## [0.4.0] - 2020-09-28 143 | ### Added 144 | - Add support for filtering nested params (#58) 145 | 146 | ## [0.3.1] - 2020-09-01 147 | ### Fixed 148 | - Release for Python 3.8 149 | 150 | ## [0.3.0] - 2020-06-02 151 | ### Added 152 | - Add source snippets to backtrace lines (#50) 153 | 154 | ### Fixed 155 | - Fix "AttributeError: module 'os' has no attribute 'getloadavg'" error on 156 | Windows (#53) 157 | - Fix snippet offset bug (#54) 158 | 159 | ## [0.2.1] - 2020-01-13 160 | - Fix context for threads (#41, @dstuebe) 161 | 162 | ## [0.2.0] - 2018-07-18 163 | ### Added 164 | - Added Plugin system so users can extend the Honeybadger library to any framework (thanks @ifoukarakis!) 165 | - Added Flask support (@ifoukarakis) 166 | ### Changed 167 | - Moved DjangoHoneybadgerMiddleware to contrib.django and added DeprecationWarning at old import path 168 | 169 | ## [0.1.2] - 2018-01-16 170 | ### Fixed 171 | - Fixed issue with exception reporting failing when stacktrace includes a non-file source path (eg. Cython extension) 172 | 173 | ## [0.1.1] - 2017-12-08 174 | ### Changed 175 | - Changed how thread local variables are handled in order to fix issues with threads losing honeybadger config data 176 | 177 | ## [0.1.0] - 2017-11-03 178 | ### Added 179 | - Block calls to honeybadger server when development like environment unless 180 | explicitly forced. 181 | 182 | ### Changed 183 | - Remove unused `trace_threshold` config option. 184 | 185 | ## [0.0.6] - 2017-03-27 186 | ### Fixed 187 | - Added support for Django 1.10 middleware changes. 188 | 189 | ## [0.0.5] - 2016-10-11 190 | ### Fixed 191 | - Python 3 setup.py bug. 192 | 193 | ## [0.0.4] - 2016-10-11 194 | ### Fixed 195 | - setup.py version importing bug. 196 | 197 | ## [0.0.3] - 2016-10-11 198 | ### Fixed 199 | - Python 3 bug in `utils.filter_dict` - vineesha 200 | 201 | ## [0.0.2][0.0.2] 202 | ### Fixed 203 | - Add Python 3 compatibility. -@demsullivan 204 | - Convert exception to error message using `str()` (#13) -@krzysztofwos 205 | -------------------------------------------------------------------------------- /honeybadger/contrib/asgi.py: -------------------------------------------------------------------------------- 1 | from honeybadger import honeybadger, plugins, utils 2 | from honeybadger.utils import get_duration 3 | import logging 4 | import time 5 | import urllib 6 | import inspect 7 | import asyncio 8 | import json 9 | from typing import Dict, Any, Optional, Callable, Awaitable, Union, Tuple, List, cast 10 | 11 | logger = logging.getLogger(__name__) 12 | 13 | 14 | def _looks_like_asgi3(app) -> bool: 15 | # https://github.com/encode/uvicorn/blob/bf1c64e2c141971c546671c7dc91b8ccf0afeb7d/uvicorn/config.py#L327 16 | if inspect.isclass(app): 17 | return hasattr(app, "__await__") 18 | elif inspect.isfunction(app): 19 | return asyncio.iscoroutinefunction(app) 20 | else: 21 | call = getattr(app, "__call__", None) 22 | return asyncio.iscoroutinefunction(call) 23 | return False 24 | 25 | 26 | def _get_headers(scope: dict) -> Dict[str, str]: 27 | headers: Dict[str, str] = {} 28 | for raw_key, raw_value in scope["headers"]: 29 | key = raw_key.decode("latin-1") 30 | value = raw_value.decode("latin-1") 31 | if key in headers: 32 | headers[key] = headers[key] + ", " + value 33 | else: 34 | headers[key] = value 35 | return headers 36 | 37 | 38 | def _get_query(scope: dict) -> Optional[str]: 39 | qs = scope.get("query_string") 40 | if not qs: 41 | return None 42 | return urllib.parse.unquote(qs.decode("latin-1")) 43 | 44 | 45 | def _get_url(scope: dict, default_scheme: str, host: Optional[str] = None) -> str: 46 | scheme = scope.get("scheme", default_scheme) 47 | server = scope.get("server") 48 | path = scope.get("root_path", "") + scope.get("path", "") 49 | if host: 50 | return "%s://%s%s" % (scheme, host, path) 51 | 52 | if server is not None: 53 | host, port = server 54 | default_port = {"http": 80, "https": 443, "ws": 80, "wss": 443}[scheme] 55 | if port != default_port: 56 | return "%s://%s:%s%s" % (scheme, host, port, path) 57 | return "%s://%s%s" % (scheme, host, path) 58 | return path 59 | 60 | 61 | def _get_body(scope: dict) -> Optional[Union[Dict[Any, Any], str]]: 62 | body = scope.get("body") 63 | if body is None: 64 | return None 65 | 66 | try: 67 | return json.loads(body) 68 | except: 69 | return urllib.parse.unquote(body.decode("latin-1")) 70 | 71 | 72 | def _as_context(scope: dict) -> Dict[str, Any]: 73 | ctx: Dict[str, Any] = {} 74 | if scope.get("type") in ("http", "websocket"): 75 | ctx["method"] = scope.get("method") 76 | ctx["headers"] = headers = _get_headers(scope) 77 | ctx["query_string"] = _get_query(scope) 78 | host_header = headers.get("host") 79 | ctx["url"] = _get_url( 80 | scope, "http" if scope["type"] == "http" else "ws", host_header 81 | ) 82 | body = _get_body(scope) 83 | if body is not None: 84 | ctx["body"] = body 85 | 86 | ctx["client"] = scope.get("client") # pii info can be filtered from hb config. 87 | 88 | # TODO: should we look at "endpoint"? 89 | return utils.filter_dict(ctx, honeybadger.config.params_filters) 90 | 91 | 92 | class ASGIHoneybadger(plugins.Plugin): 93 | __slots__ = ("__call__", "app") 94 | 95 | def __init__(self, app, **kwargs): 96 | super().__init__("ASGI") 97 | 98 | if kwargs: 99 | honeybadger.configure(**kwargs) 100 | 101 | self.app = app 102 | 103 | if _looks_like_asgi3(app): 104 | self.__call__ = self._run_asgi3 105 | else: 106 | self.__call__ = self._run_asgi2 107 | 108 | plugins.default_plugin_manager.register(self) 109 | 110 | def _run_asgi2(self, scope): 111 | async def inner(receive, send): 112 | return await self._run_request(scope, receive, send, self.app(scope)) 113 | 114 | return inner 115 | 116 | async def _run_asgi3(self, scope, receive, send): 117 | return await self._run_request( 118 | scope, receive, send, lambda recv, snd: self.app(scope, recv, snd) 119 | ) 120 | 121 | async def _run_request(self, scope, receive, send, app_callable): 122 | # TODO: Should we check recursive middleware stacks? 123 | # See: https://github.com/getsentry/sentry-python/blob/master/sentry_sdk/integrations/asgi.py#L112 124 | start = time.time() 125 | status = None 126 | 127 | async def send_wrapper(message): 128 | nonlocal status 129 | if message.get("type") == "http.response.start": 130 | status = message.get("status") 131 | await send(message) 132 | 133 | try: 134 | return await app_callable(receive, send_wrapper) 135 | except Exception as exc: 136 | honeybadger.notify(exception=exc, context=_as_context(scope)) 137 | raise 138 | finally: 139 | try: 140 | asgi_config = honeybadger.config.insights_config.asgi 141 | if honeybadger.config.insights_enabled and not asgi_config.disabled: 142 | payload = { 143 | "method": scope.get("method"), 144 | "path": scope.get("path"), 145 | "status": status, 146 | "duration": get_duration(start), 147 | } 148 | 149 | if asgi_config.include_params: 150 | raw_qs = scope.get("query_string", b"") 151 | params = {} 152 | if raw_qs: 153 | parsed = urllib.parse.parse_qs(raw_qs.decode()) 154 | for key, values in parsed.items(): 155 | params[key] = values[0] if len(values) == 1 else values 156 | 157 | payload["params"] = utils.filter_dict( 158 | params, 159 | honeybadger.config.params_filters, 160 | remove_keys=True, 161 | ) 162 | 163 | honeybadger.event("asgi.request", payload) 164 | honeybadger.reset_context() 165 | except Exception as e: 166 | logger.warning( 167 | f"Exception while sending Honeybadger event: {e}", exc_info=True 168 | ) 169 | 170 | def supports(self, config, context): 171 | return context.get("asgi") is not None 172 | 173 | def generate_payload(self, default_payload, config, context): 174 | return utils.filter_dict(default_payload, honeybadger.config.params_filters) 175 | -------------------------------------------------------------------------------- /honeybadger/payload.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import traceback 3 | import os 4 | import logging 5 | import inspect 6 | import uuid 7 | from six.moves import range 8 | from six.moves import zip 9 | from io import open 10 | from datetime import datetime, timezone 11 | 12 | from .version import __version__ 13 | from .plugins import default_plugin_manager 14 | from .utils import filter_dict 15 | 16 | logger = logging.getLogger("honeybadger.payload") 17 | 18 | # Prevent infinite loops in exception cause chains 19 | MAX_CAUSE_DEPTH = 15 20 | 21 | 22 | def error_payload(exception, exc_traceback, config, fingerprint=None, tags=None): 23 | def _filename(name): 24 | return name.replace(config.project_root, "[PROJECT_ROOT]") 25 | 26 | def is_not_honeybadger_frame(frame): 27 | # TODO: is there a better way to do this? 28 | # simply looking for 'honeybadger' in the path doesn't seem 29 | # specific enough but this approach seems too specific and 30 | # would need to be updated if we re-factored the call stack 31 | # for building a payload. 32 | return not ( 33 | "honeybadger" in frame[0] 34 | and frame[2] 35 | in ["notify", "_send_notice", "create_payload", "error_payload"] 36 | ) 37 | 38 | def prepare_exception_payload(exception, exclude=None): 39 | return { 40 | "token": str(uuid.uuid4()), 41 | "class": type(exception) is dict 42 | and exception["error_class"] 43 | or exception.__class__.__name__, 44 | "message": type(exception) is dict 45 | and exception["error_message"] 46 | or str(exception), 47 | "backtrace": [ 48 | dict( 49 | number=f[1], 50 | file=_filename(f[0]), 51 | method=f[2], 52 | source=read_source(f), 53 | ) 54 | for f in reversed(tb) 55 | ], 56 | } 57 | 58 | def extract_exception_causes(exception): 59 | """ 60 | Traverses the __cause__ chain of an exception and returns a list of prepared payloads. 61 | Limits depth to prevent infinite loops from circular references. 62 | """ 63 | causes = [] 64 | depth = 0 65 | 66 | while ( 67 | getattr(exception, "__cause__", None) is not None 68 | and depth < MAX_CAUSE_DEPTH 69 | ): 70 | exception = exception.__cause__ 71 | causes.append(prepare_exception_payload(exception)) 72 | depth += 1 73 | 74 | if depth == MAX_CAUSE_DEPTH: 75 | causes.append( 76 | { 77 | "token": str(uuid.uuid4()), 78 | "class": "HoneybadgerWarning", 79 | "type": "HoneybadgerWarning", 80 | "message": f"Exception cause chain truncated after {MAX_CAUSE_DEPTH} levels. Possible circular reference.", 81 | } 82 | ) 83 | 84 | return causes 85 | 86 | if exc_traceback: 87 | tb = traceback.extract_tb(exc_traceback) 88 | else: 89 | tb = [f for f in traceback.extract_stack() if is_not_honeybadger_frame(f)] 90 | 91 | logger.debug(tb) 92 | 93 | payload = prepare_exception_payload(exception) 94 | payload["causes"] = extract_exception_causes(exception) 95 | payload["tags"] = tags or [] 96 | 97 | if fingerprint is not None: 98 | payload["fingerprint"] = fingerprint and str(fingerprint).strip() or None 99 | 100 | return payload 101 | 102 | 103 | def read_source(frame, source_radius=3): 104 | if os.path.isfile(frame[0]): 105 | with open(frame[0], "rt", encoding="utf-8") as f: 106 | contents = f.readlines() 107 | 108 | start = max(1, frame[1] - source_radius) 109 | end = min(len(contents), frame[1] + source_radius) 110 | 111 | return dict(zip(range(start, end + 1), contents[start - 1 : end])) 112 | 113 | return {} 114 | 115 | 116 | def server_payload(config): 117 | return { 118 | "project_root": config.project_root, 119 | "environment_name": config.environment, 120 | "hostname": config.hostname, 121 | "time": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), 122 | "pid": os.getpid(), 123 | "stats": stats_payload(), 124 | } 125 | 126 | 127 | def stats_payload(): 128 | try: 129 | import psutil 130 | except ImportError: 131 | return {} 132 | else: 133 | s = psutil.virtual_memory() 134 | loadavg = psutil.getloadavg() 135 | 136 | free = float(s.free) / 1048576.0 137 | buffers = hasattr(s, "buffers") and float(s.buffers) / 1048576.0 or 0.0 138 | cached = hasattr(s, "cached") and float(s.cached) / 1048576.0 or 0.0 139 | total_free = free + buffers + cached 140 | payload = {} 141 | 142 | payload["mem"] = { 143 | "total": float(s.total) / 1048576.0, # bytes -> megabytes 144 | "free": free, 145 | "buffers": buffers, 146 | "cached": cached, 147 | "total_free": total_free, 148 | } 149 | 150 | payload["load"] = dict(zip(("one", "five", "fifteen"), loadavg)) 151 | 152 | return payload 153 | 154 | 155 | def create_payload( 156 | exception, 157 | exc_traceback=None, 158 | config=None, 159 | context=None, 160 | fingerprint=None, 161 | correlation_context=None, 162 | tags=None, 163 | ): 164 | # if using local_variables get them 165 | local_variables = None 166 | if config and config.report_local_variables: 167 | try: 168 | local_variables = filter_dict( 169 | inspect.trace()[-1][0].f_locals, config.params_filters 170 | ) 171 | except Exception as e: 172 | pass 173 | 174 | if exc_traceback is None: 175 | exc_traceback = sys.exc_info()[2] 176 | 177 | # if context is None, Initialize as an emptty dict 178 | if not context: 179 | context = {} 180 | 181 | payload = { 182 | "notifier": { 183 | "name": "Honeybadger for Python", 184 | "url": "https://github.com/honeybadger-io/honeybadger-python", 185 | "version": __version__, 186 | }, 187 | "error": error_payload(exception, exc_traceback, config, fingerprint, tags), 188 | "server": server_payload(config), 189 | "request": {"context": context, "local_variables": local_variables}, 190 | } 191 | 192 | if correlation_context: 193 | payload["correlation_context"] = correlation_context 194 | 195 | return default_plugin_manager.generate_payload(payload, config, context) 196 | -------------------------------------------------------------------------------- /honeybadger/tests/contrib/test_celery.py: -------------------------------------------------------------------------------- 1 | import time 2 | import re 3 | from unittest.mock import patch, MagicMock 4 | 5 | from celery import Celery 6 | from honeybadger.contrib.celery import CeleryHoneybadger, CeleryPlugin 7 | from honeybadger.tests.utils import with_config 8 | 9 | 10 | @patch("honeybadger.honeybadger.reset_context") 11 | @patch("honeybadger.honeybadger.notify") 12 | def test_notify_from_task_failure(notify, reset_context): 13 | from celery.signals import task_failure, task_postrun 14 | 15 | app = Celery(__name__, broker="memory://") 16 | exception = Exception("Test exception") 17 | hb = CeleryHoneybadger(app, report_exceptions=True) 18 | 19 | # Send task_failure event 20 | task_failure.send( 21 | sender=app, task_id="hi", task_name="tasks.add", exception=exception 22 | ) 23 | 24 | assert notify.call_count == 1 25 | assert notify.call_args[1]["exception"] == exception 26 | 27 | task_postrun.send( 28 | sender=app, task_id="hi", task_name="tasks.add", task={"name": "tasks.add"} 29 | ) 30 | 31 | assert reset_context.call_count == 1 32 | 33 | hb.tearDown() 34 | 35 | 36 | @patch("honeybadger.honeybadger.notify") 37 | def test_notify_not_called_from_task_failure(mock): 38 | from celery.signals import task_failure 39 | 40 | app = Celery(__name__, broker="memory://") 41 | hb = CeleryHoneybadger(app, report_exceptions=False) 42 | 43 | # Send task_failure event 44 | task_failure.send( 45 | sender=app, task_name="tasks.add", exception=Exception("Test exception") 46 | ) 47 | 48 | assert mock.call_count == 0 49 | hb.tearDown() 50 | 51 | 52 | def test_plugin_payload(): 53 | test_task = MagicMock() 54 | test_task.name = "test_task" 55 | test_task.max_retries = 10 56 | test_task.request = MagicMock( 57 | id="test_id", 58 | name="test_task", 59 | args=(1, 2), 60 | retries=0, 61 | max_retries=10, 62 | kwargs={"foo": "bar"}, 63 | ) 64 | 65 | with patch("celery.current_task", test_task): 66 | plugin = CeleryPlugin() 67 | payload = plugin.generate_payload({"request": {}}, {}, {}) 68 | request = payload["request"] 69 | assert request["component"] == "unittest.mock" 70 | assert request["action"] == "test_task" 71 | assert request["params"]["args"] == [1, 2] 72 | assert request["params"]["kwargs"] == {"foo": "bar"} 73 | assert request["context"]["task_id"] == "test_id" 74 | assert request["context"]["retries"] == 0 75 | assert request["context"]["max_retries"] == 10 76 | 77 | 78 | def setup_celery_hb(): 79 | from celery.signals import worker_ready 80 | 81 | app = Celery(__name__, broker="memory://localhost/") 82 | app.conf.update( 83 | HONEYBADGER_INSIGHTS_ENABLED=True, 84 | HONEYBADGER_API_KEY="test_api_key", 85 | ) 86 | hb = CeleryHoneybadger(app) 87 | worker_ready.send(sender=app) 88 | time.sleep(0.2) 89 | return app, hb 90 | 91 | 92 | @patch("honeybadger.honeybadger.event") 93 | def test_finished_task_event(mock_event): 94 | _, hb = setup_celery_hb() 95 | 96 | task = MagicMock() 97 | task.name = "test_task" 98 | task.request.retries = 0 99 | task.request.group = None 100 | task.request.args = [] 101 | task.request.kwargs = {} 102 | 103 | hb._on_task_prerun("test_task_id", task) 104 | hb._on_task_postrun("test_task_id", task, state="SUCCESS") 105 | 106 | # Verify honeybadger.event was called directly 107 | assert mock_event.call_count == 1 108 | assert mock_event.call_args[0][0] == "celery.task_finished" 109 | 110 | payload = mock_event.call_args[0][1] 111 | assert payload["task_id"] == "test_task_id" 112 | assert payload["task_name"] == "test_task" 113 | assert payload["state"] == "SUCCESS" 114 | 115 | hb.tearDown() 116 | 117 | 118 | @with_config({"insights_config": {"celery": {"include_args": True}}}) 119 | @patch("honeybadger.honeybadger.event") 120 | def test_includes_task_args(mock_event): 121 | _, hb = setup_celery_hb() 122 | 123 | task = MagicMock() 124 | task.request.group = None 125 | task.name = "test_task" 126 | task.request.retries = 1 127 | task.request.args = [1, 2] 128 | task.request.kwargs = {"foo": "bar", "password": "secret"} 129 | 130 | hb._on_task_prerun("test_task_id", task) 131 | hb._on_task_postrun("test_task_id", task, state="SUCCESS") 132 | 133 | assert mock_event.call_count == 1 134 | assert mock_event.call_args[0][0] == "celery.task_finished" 135 | 136 | payload = mock_event.call_args[0][1] 137 | 138 | assert payload["task_id"] == "test_task_id" 139 | assert payload["task_name"] == "test_task" 140 | assert payload["args"] == [1, 2] 141 | assert payload["kwargs"] == {"foo": "bar"} # password should be filtered out 142 | assert payload["retries"] == 1 143 | assert payload["state"] == "SUCCESS" 144 | assert payload["group"] is None 145 | assert payload["duration"] > 0 146 | 147 | hb.tearDown() 148 | 149 | 150 | # Test context propagation 151 | @patch("honeybadger.honeybadger.event") 152 | @patch("honeybadger.honeybadger._get_event_context") 153 | @patch("honeybadger.honeybadger.set_event_context") 154 | def test_context_propagation(mock_set_context, mock_get_context, mock_event): 155 | """Test that context is properly propagated from publish to execution""" 156 | _, hb = setup_celery_hb() 157 | 158 | test_context = {"request_id": "test-123", "user_id": "456"} 159 | mock_get_context.return_value = test_context 160 | 161 | headers = {} 162 | hb._on_before_task_publish(headers=headers) 163 | 164 | assert headers["honeybadger_event_context"] == test_context 165 | 166 | task = MagicMock() 167 | task.request.honeybadger_event_context = test_context 168 | 169 | hb._on_task_prerun("test_task_id", task) 170 | 171 | mock_set_context.assert_called_once_with(test_context) 172 | 173 | hb.tearDown() 174 | 175 | 176 | @patch("honeybadger.honeybadger.events_worker") 177 | def test_worker_process_init(mock_events_worker): 178 | """Test that events worker is restarted in new worker process""" 179 | _, hb = setup_celery_hb() 180 | 181 | hb._on_worker_process_init() 182 | 183 | mock_events_worker.restart.assert_called_once() 184 | 185 | hb.tearDown() 186 | 187 | 188 | @with_config({"insights_config": {"celery": {"disabled": True}}}) 189 | def test_can_disable(): 190 | app, hb = setup_celery_hb() 191 | task = MagicMock() 192 | hb._on_task_postrun("test_task_id", task, None, state="SUCCESS") 193 | assert task.send_event.call_count == 0 194 | hb.tearDown() 195 | 196 | 197 | @with_config({"insights_config": {"celery": {"exclude_tasks": ["test_task"]}}}) 198 | def test_exclude_tasks_with_string(): 199 | app, hb = setup_celery_hb() 200 | task = MagicMock() 201 | task.name = "test_task" 202 | hb._on_task_postrun("test_task_id", task, None, state="SUCCESS") 203 | assert task.send_event.call_count == 0 204 | hb.tearDown() 205 | 206 | 207 | @with_config( 208 | {"insights_config": {"celery": {"exclude_tasks": [re.compile(r"test_.*_task")]}}} 209 | ) 210 | def test_exclude_tasks_with_regex(): 211 | app, hb = setup_celery_hb() 212 | task = MagicMock() 213 | task.name = "test_the_task" 214 | hb._on_task_postrun("test_task_id", task, None, state="SUCCESS") 215 | assert task.send_event.call_count == 0 216 | hb.tearDown() 217 | -------------------------------------------------------------------------------- /honeybadger/config.py: -------------------------------------------------------------------------------- 1 | import os 2 | import socket 3 | import re 4 | import logging 5 | 6 | from dataclasses import is_dataclass, dataclass, field, fields, MISSING 7 | from typing import List, Callable, Any, Dict, Optional, ClassVar, Union, Pattern, Tuple 8 | 9 | logger = logging.getLogger(__name__) 10 | 11 | 12 | def default_excluded_queries() -> List[Union[str, Pattern[Any]]]: 13 | return [ 14 | re.compile(r"^PRAGMA"), 15 | re.compile(r"^SHOW\s"), 16 | re.compile(r"^SELECT .* FROM information_schema\."), 17 | re.compile(r"^SELECT .* FROM pg_catalog\."), 18 | re.compile(r"^BEGIN"), 19 | re.compile(r"^COMMIT"), 20 | re.compile(r"^ROLLBACK"), 21 | re.compile(r"^SAVEPOINT"), 22 | re.compile(r"^RELEASE SAVEPOINT"), 23 | re.compile(r"^ROLLBACK TO SAVEPOINT"), 24 | re.compile(r"^VACUUM"), 25 | re.compile(r"^ANALYZE"), 26 | re.compile(r"^SET\s"), 27 | re.compile(r".*django_migrations.*"), 28 | re.compile(r".*django_admin_log.*"), 29 | re.compile(r".*auth_permission.*"), 30 | re.compile(r".*auth_group.*"), 31 | re.compile(r".*auth_group_permissions.*"), 32 | re.compile(r".*django_session.*"), 33 | ] 34 | 35 | 36 | @dataclass 37 | class DBConfig: 38 | disabled: bool = False 39 | exclude_queries: List[Union[str, Pattern]] = field( 40 | default_factory=default_excluded_queries 41 | ) 42 | include_params: bool = False 43 | 44 | 45 | @dataclass 46 | class DjangoConfig: 47 | disabled: bool = False 48 | include_params: bool = False 49 | 50 | 51 | @dataclass 52 | class FlaskConfig: 53 | disabled: bool = False 54 | include_params: bool = False 55 | 56 | 57 | @dataclass 58 | class ASGIConfig: 59 | disabled: bool = False 60 | include_params: bool = False 61 | 62 | 63 | @dataclass 64 | class CeleryConfig: 65 | disabled: bool = False 66 | exclude_tasks: List[Union[str, Pattern]] = field(default_factory=list) 67 | include_args: bool = False 68 | 69 | 70 | @dataclass 71 | class InsightsConfig: 72 | db: DBConfig = field(default_factory=DBConfig) 73 | django: DjangoConfig = field(default_factory=DjangoConfig) 74 | flask: FlaskConfig = field(default_factory=FlaskConfig) 75 | celery: CeleryConfig = field(default_factory=CeleryConfig) 76 | asgi: ASGIConfig = field(default_factory=ASGIConfig) 77 | 78 | 79 | @dataclass 80 | class BaseConfig: 81 | DEVELOPMENT_ENVIRONMENTS: ClassVar[List[str]] = ["development", "dev", "test"] 82 | 83 | api_key: str = "" 84 | project_root: str = field(default_factory=os.getcwd) 85 | environment: str = "production" 86 | hostname: str = field(default_factory=socket.gethostname) 87 | endpoint: str = "https://api.honeybadger.io" 88 | params_filters: List[str] = field( 89 | default_factory=lambda: [ 90 | "password", 91 | "password_confirmation", 92 | "credit_card", 93 | "CSRF_COOKIE", 94 | ] 95 | ) 96 | development_environments: List[str] = field( 97 | default_factory=lambda: BaseConfig.DEVELOPMENT_ENVIRONMENTS 98 | ) 99 | force_report_data: bool = False 100 | force_sync: bool = False 101 | excluded_exceptions: List[str] = field(default_factory=list) 102 | report_local_variables: bool = False 103 | before_notify: Callable[[Any], Any] = lambda notice: notice 104 | 105 | insights_enabled: bool = False 106 | insights_config: InsightsConfig = field(default_factory=InsightsConfig) 107 | 108 | before_event: Callable[[Any], Any] = lambda _: None 109 | 110 | events_sample_rate: int = 100 111 | events_batch_size: int = 1000 112 | events_max_queue_size: int = 10_000 113 | events_timeout: float = 5.0 114 | events_max_batch_retries: int = 3 115 | events_throttle_wait: float = 60.0 116 | 117 | 118 | class Configuration(BaseConfig): 119 | def __init__(self, **kwargs): 120 | super().__init__() 121 | self.set_12factor_config() 122 | self.set_config_from_dict(kwargs) 123 | 124 | def set_12factor_config(self): 125 | for f in fields(self): 126 | env_val = os.environ.get(f"HONEYBADGER_{f.name.upper()}") 127 | if env_val is not None: 128 | typ = f.type 129 | try: 130 | if typ == list or typ == List[str]: 131 | val = env_val.split(",") 132 | elif typ == int: 133 | val = int(env_val) 134 | elif typ == bool: 135 | val = env_val.lower() in ("true", "1", "yes") 136 | else: 137 | val = env_val 138 | setattr(self, f.name, val) 139 | except Exception: 140 | pass 141 | 142 | def set_config_from_dict(self, config: Dict[str, Any]): 143 | filtered = filter_and_warn_unknown(config, self.__class__) 144 | for k, v in filtered.items(): 145 | current_val = getattr(self, k) 146 | # If current_val is a dataclass and v is a dict, merge recursively 147 | if hasattr(current_val, "__dataclass_fields__") and isinstance(v, dict): 148 | # Merge current values and updates 149 | current_dict = { 150 | f.name: getattr(current_val, f.name) for f in fields(current_val) 151 | } 152 | merged = {**current_dict, **v} 153 | hydrated = dataclass_from_dict(type(current_val), merged) 154 | setattr(self, k, hydrated) 155 | else: 156 | setattr(self, k, v) 157 | 158 | def is_dev(self): 159 | """Returns wether you are in a dev environment or not 160 | 161 | Default dev environments are defined in the constant DEVELOPMENT_ENVIRONMENTS 162 | 163 | :rtype: bool 164 | """ 165 | return self.environment in self.development_environments 166 | 167 | @property 168 | def is_aws_lambda_environment(self): 169 | """ 170 | Checks if you are in an AWS Lambda environment by checking for the existence 171 | of "AWS_LAMBDA_FUNCTION_NAME" in the environment variables. 172 | 173 | :rtype: bool 174 | """ 175 | return os.environ.get("AWS_LAMBDA_FUNCTION_NAME") is not None 176 | 177 | 178 | def filter_and_warn_unknown(opts: Dict[str, Any], schema: Any) -> Dict[str, Any]: 179 | if is_dataclass(schema): 180 | if isinstance(schema, type): # It's a class 181 | schema_name = schema.__name__ 182 | else: # It's an instance 183 | schema_name = type(schema).__name__ 184 | allowed = {f.name for f in fields(schema)} 185 | else: 186 | raise TypeError(f"Expected a dataclass type or instance, got: {schema!r}") 187 | 188 | unknown = set(opts) - allowed 189 | if unknown: 190 | logger.warning( 191 | "Unknown %s option(s): %s", 192 | schema_name, 193 | ", ".join(sorted(unknown)), 194 | ) 195 | return {k: opts[k] for k in opts.keys() & allowed} 196 | 197 | 198 | def dataclass_from_dict(klass, d): 199 | """ 200 | Recursively build a dataclass instance from a dict. 201 | """ 202 | if not is_dataclass(klass): 203 | return d 204 | filtered = filter_and_warn_unknown(d, klass) 205 | kwargs = {} 206 | for f in fields(klass): 207 | if f.name in d: 208 | val = d[f.name] 209 | if is_dataclass(f.type) and isinstance(val, dict): 210 | val = dataclass_from_dict(f.type, val) 211 | kwargs[f.name] = val 212 | return klass(**kwargs) 213 | -------------------------------------------------------------------------------- /honeybadger/contrib/celery.py: -------------------------------------------------------------------------------- 1 | import time 2 | import logging 3 | 4 | from honeybadger import honeybadger 5 | from honeybadger.plugins import Plugin, default_plugin_manager 6 | from honeybadger.utils import filter_dict, extract_honeybadger_config, get_duration 7 | 8 | logger = logging.getLogger(__name__) 9 | 10 | 11 | class CeleryPlugin(Plugin): 12 | def __init__(self): 13 | super().__init__("Celery") 14 | 15 | def supports(self, config, context): 16 | from celery import current_task 17 | 18 | """ 19 | Check whether this is a a celery task or not. 20 | :param config: honeybadger configuration. 21 | :param context: current honeybadger configuration. 22 | :return: True if this is a celery task, False else. 23 | """ 24 | return current_task != None 25 | 26 | def generate_payload(self, default_payload, config, context): 27 | """ 28 | Generate payload by checking celery task object. 29 | :param context: current context. 30 | :param config: honeybadger configuration. 31 | :return: a dict with the generated payload. 32 | """ 33 | from celery import current_task 34 | 35 | # Ensure we have a mutable context dictionary 36 | context = dict(context or {}) 37 | 38 | # Add Celery task information to context 39 | context.update( 40 | task_id=current_task.request.id, 41 | retries=current_task.request.retries, 42 | max_retries=current_task.max_retries, 43 | ) 44 | 45 | payload = { 46 | "component": current_task.__module__, 47 | "action": current_task.name, 48 | "params": { 49 | "args": list(current_task.request.args), 50 | "kwargs": current_task.request.kwargs, 51 | }, 52 | "context": context, 53 | } 54 | default_payload["request"].update(payload) 55 | return default_payload 56 | 57 | 58 | class CeleryHoneybadger(object): 59 | def __init__(self, app, report_exceptions=False): 60 | self.app = app 61 | self.report_exceptions = report_exceptions 62 | default_plugin_manager.register(CeleryPlugin()) 63 | if app is not None: 64 | self.init_app() 65 | 66 | def init_app(self): 67 | """ 68 | Initialize honeybadger and listen for errors. 69 | """ 70 | from celery.signals import ( 71 | task_failure, 72 | task_postrun, 73 | task_prerun, 74 | before_task_publish, 75 | worker_process_init, 76 | ) 77 | 78 | self._task_starts = {} 79 | self._initialize_honeybadger(self.app.conf) 80 | 81 | if self.report_exceptions: 82 | task_failure.connect(self._on_task_failure, weak=False) 83 | task_postrun.connect(self._on_task_postrun, weak=False) 84 | 85 | if honeybadger.config.insights_enabled: 86 | # Enable task events, as we need to listen to 87 | # task-finished events 88 | worker_process_init.connect(self._on_worker_process_init, weak=False) 89 | task_prerun.connect(self._on_task_prerun, weak=False) 90 | before_task_publish.connect(self._on_before_task_publish, weak=False) 91 | 92 | def _initialize_honeybadger(self, config): 93 | """ 94 | Initializes honeybadger using the given config object. 95 | :param dict config: a dict or dict-like object that contains honeybadger configuration properties. 96 | """ 97 | config_kwargs = extract_honeybadger_config(config) 98 | 99 | if not config_kwargs.get("api_key"): 100 | return 101 | 102 | honeybadger.configure(**config_kwargs) 103 | honeybadger.config.set_12factor_config() # environment should override celery settings 104 | 105 | def _on_worker_process_init(self, *args, **kwargs): 106 | # Restart the events worker to ensure it is running in the new worker 107 | # process. 108 | try: 109 | honeybadger.events_worker.restart() 110 | except Exception as e: 111 | logger.warning(f"Warning: Failed to restart Honeybadger events worker: {e}") 112 | 113 | def _on_before_task_publish(self, sender=None, body=None, headers=None, **kwargs): 114 | # Inject Honeybadger event context into task headers 115 | if headers is not None: 116 | current_context = honeybadger._get_event_context() 117 | if current_context: 118 | headers["honeybadger_event_context"] = current_context 119 | 120 | def _on_task_prerun(self, task_id=None, task=None, *args, **kwargs): 121 | self._task_starts[task_id] = time.time() 122 | 123 | if task: 124 | context = getattr(task.request, "honeybadger_event_context", None) 125 | if context: 126 | honeybadger.set_event_context(context) 127 | 128 | def _on_task_postrun(self, task_id=None, task=None, *args, **kwargs): 129 | """ 130 | Callback executed after a task is finished. 131 | """ 132 | 133 | insights_config = honeybadger.config.insights_config 134 | 135 | exclude = insights_config.celery.exclude_tasks 136 | should_exclude = exclude and any( 137 | ( 138 | pattern.search(task.name) 139 | if hasattr(pattern, "search") 140 | else pattern == task.name 141 | ) 142 | for pattern in exclude 143 | ) 144 | 145 | if ( 146 | honeybadger.config.insights_enabled 147 | and not insights_config.celery.disabled 148 | and not should_exclude 149 | ): 150 | payload = { 151 | "task_id": task_id, 152 | "task_name": task.name, 153 | "retries": task.request.retries, 154 | "group": task.request.group, 155 | "state": kwargs["state"], 156 | "duration": get_duration(self._task_starts.pop(task_id, None)), 157 | } 158 | 159 | if insights_config.celery.include_args: 160 | payload["args"] = task.request.args 161 | payload["kwargs"] = filter_dict( 162 | task.request.kwargs, 163 | honeybadger.config.params_filters, 164 | remove_keys=True, 165 | ) 166 | 167 | honeybadger.event("celery.task_finished", payload) 168 | 169 | honeybadger.reset_context() 170 | 171 | def _on_task_failure(self, *args, **kwargs): 172 | """ 173 | Report exception to honeybadger when a task fails. 174 | """ 175 | honeybadger.notify(exception=kwargs["exception"]) 176 | 177 | def tearDown(self): 178 | """ 179 | Disconnects celery signals. 180 | """ 181 | from celery.signals import task_failure, task_postrun 182 | 183 | task_postrun.disconnect(self._on_task_postrun) 184 | if self.report_exceptions: 185 | task_failure.disconnect(self._on_task_failure) 186 | 187 | if honeybadger.config.insights_enabled: 188 | from celery.signals import ( 189 | task_prerun, 190 | worker_process_init, 191 | before_task_publish, 192 | ) 193 | 194 | task_prerun.disconnect(self._on_task_prerun) 195 | worker_process_init.disconnect(self._on_worker_process_init, weak=False) 196 | before_task_publish.disconnect(self._on_before_task_publish, weak=False) 197 | 198 | # Keep the misspelled method for backward compatibility 199 | def tearDowm(self): 200 | """ 201 | Disconnects celery signals. (backward compatibility method) 202 | """ 203 | self.tearDown() 204 | -------------------------------------------------------------------------------- /honeybadger/events_worker.py: -------------------------------------------------------------------------------- 1 | import os 2 | import time 3 | import threading 4 | import logging 5 | from collections import deque 6 | from typing import Deque, Dict, Any, Optional, Tuple, List 7 | 8 | from .protocols import Connection 9 | from .config import Configuration 10 | from .types import EventsSendStatus, EventsSendResult, Event 11 | 12 | 13 | class EventsWorker: 14 | """ 15 | Asynchronously batches events and sends them to a backend connection, 16 | applying retry logic, rate-limit backoff, and drop-on-overflow. 17 | """ 18 | 19 | _DROP_LOG_INTERVAL = 60.0 # seconds 20 | 21 | def __init__( 22 | self, 23 | connection: Connection, 24 | config: Configuration, 25 | logger: Optional[logging.Logger] = None, 26 | ) -> None: 27 | self.connection = connection 28 | self.config = config 29 | 30 | self.log = logger or logging.getLogger(__name__) 31 | self._lock = threading.RLock() 32 | self._batch_ready_event = threading.Event() 33 | 34 | self._queue: Deque[Event] = deque() 35 | self._batches: Deque[Tuple[List[Event], int]] = deque() 36 | 37 | self._throttled = False 38 | self._stop = False 39 | self._dropped = 0 40 | self._last_drop_log = time.monotonic() 41 | self._start_time = time.monotonic() 42 | 43 | self._thread = threading.Thread( 44 | target=self._run, 45 | name=f"honeybadger-events-worker-{os.getpid()}", 46 | daemon=True, 47 | ) 48 | self._thread.start() 49 | self.log.debug("Events worker started") 50 | 51 | def restart(self): 52 | """Restart the batch worker thread (useful after process forking)""" 53 | if hasattr(self, "_thread") and self._thread and self._thread.is_alive(): 54 | self.shutdown() 55 | 56 | # Reset state 57 | self._stop = False 58 | 59 | self._thread = threading.Thread( 60 | target=self._run, 61 | name=f"honeybadger-events-worker-{os.getpid()}", 62 | daemon=True, 63 | ) 64 | self._thread.start() 65 | 66 | return self._thread.is_alive() 67 | 68 | def push(self, event: Event) -> bool: 69 | # Small race condition is acceptable - may slightly exceed max size briefly 70 | current_size = self._all_events_queued_len() 71 | if current_size >= self.config.events_max_queue_size: 72 | self._drop() 73 | return False 74 | 75 | self._queue.append(event) 76 | 77 | # Signal worker thread if batch size reached 78 | if len(self._queue) >= self.config.events_batch_size: 79 | self._batch_ready_event.set() 80 | 81 | return True 82 | 83 | def shutdown(self) -> None: 84 | self.log.debug("Shutting down events worker") 85 | self._stop = True 86 | self._batch_ready_event.set() # Wake up the worker thread 87 | 88 | if self._thread.is_alive(): 89 | timeout = ( 90 | max( 91 | self.config.events_timeout, 92 | self.config.events_throttle_wait, 93 | ) 94 | * 2 95 | ) 96 | self._thread.join(timeout) 97 | self.log.debug("Events worker stopped") 98 | 99 | def get_stats(self) -> Dict[str, Any]: 100 | with self._lock: 101 | return { 102 | "queue_size": len(self._queue), 103 | "batch_count": len(self._batches), 104 | "total_events": self._all_events_queued_len(), 105 | "dropped_events": self._dropped, 106 | "throttling": self._throttled, 107 | } 108 | 109 | def _run(self) -> None: 110 | """ 111 | Main loop: wait until stop or enough events to batch, then flush. 112 | """ 113 | while True: 114 | # Wait for batch ready signal or timeout 115 | self._batch_ready_event.wait(timeout=self._compute_timeout()) 116 | self._batch_ready_event.clear() 117 | 118 | # Check if we should exit (need consistent view of state) 119 | with self._lock: 120 | if self._stop and not self._queue and not self._batches: 121 | break 122 | 123 | # Perform send/retry logic 124 | self._flush() 125 | 126 | def _flush(self) -> None: 127 | """ 128 | Move queued events into a pending batch list, then attempt to send 129 | each batch with retry/backoff. Update throttled state and pending list. 130 | """ 131 | with self._lock: 132 | # If there are new queued events, package them as a fresh batch 133 | # Use popleft() which is atomic/thread-safe (unlike list() or clear()) 134 | batch = [] 135 | while True: 136 | try: 137 | batch.append(self._queue.popleft()) 138 | except IndexError: 139 | break 140 | 141 | if batch: 142 | self._batches.append((batch, 0)) 143 | self._start_time = time.monotonic() 144 | 145 | new: Deque[Tuple[List[Event], int]] = deque() 146 | throttled = False 147 | 148 | # Process each batch in FIFO order 149 | while self._batches: 150 | batch, attempts = self._batches.popleft() 151 | # If already throttled earlier this pass, skip sends 152 | if throttled: 153 | new.append((batch, attempts)) 154 | continue 155 | 156 | # Attempt to send; wrap in try/except for resiliency 157 | try: 158 | result = self.connection.send_events(self.config, batch) 159 | except Exception as err: 160 | self.log.exception("Unexpected error sending batch") 161 | result = EventsSendResult(EventsSendStatus.ERROR, str(err)) 162 | 163 | if result.status == EventsSendStatus.OK: 164 | continue 165 | 166 | attempts += 1 167 | # Rate-limited path 168 | if result.status == EventsSendStatus.THROTTLING: 169 | throttled = True 170 | self.log.warning( 171 | f"Rate limited – backing off {self.config.events_throttle_wait}s" 172 | ) 173 | else: 174 | reason = result.reason or "unknown" 175 | self.log.debug(f"Batch failed (attempt {attempts}): {reason}") 176 | 177 | # Retry or drop based on max_retries 178 | if attempts < self.config.events_max_batch_retries: 179 | new.append((batch, attempts)) 180 | else: 181 | self.log.debug(f"Dropping batch after {attempts} retries") 182 | 183 | # Replace batch list and set throttling flag 184 | self._batches = new 185 | self._throttled = throttled 186 | 187 | def _compute_timeout(self) -> float: 188 | """ 189 | Determine sleep time: use backoff if throttled, else fixed flush interval. 190 | """ 191 | if self._throttled: 192 | return self.config.events_throttle_wait 193 | return self.config.events_timeout 194 | 195 | def _drop(self) -> None: 196 | """ 197 | Increment drop counter and occasionally log a summary. 198 | """ 199 | self._dropped += 1 200 | now = time.monotonic() 201 | if now - self._last_drop_log >= self._DROP_LOG_INTERVAL: 202 | self.log.info(f"Dropped {self._dropped} events (queue full)") 203 | self._dropped = 0 204 | self._last_drop_log = now 205 | 206 | def _all_events_queued_len(self) -> int: 207 | return len(self._queue) + sum(len(b) for b, _ in self._batches) 208 | -------------------------------------------------------------------------------- /honeybadger/core.py: -------------------------------------------------------------------------------- 1 | import threading 2 | from contextlib import contextmanager 3 | import sys 4 | import logging 5 | import datetime 6 | import atexit 7 | import uuid 8 | import hashlib 9 | 10 | from typing import Optional, Dict, Any, List 11 | 12 | from honeybadger.plugins import default_plugin_manager 13 | import honeybadger.connection as connection 14 | import honeybadger.fake_connection as fake_connection 15 | from .events_worker import EventsWorker 16 | from .config import Configuration 17 | from .notice import Notice 18 | from .context_store import ContextStore 19 | 20 | logger = logging.getLogger("honeybadger") 21 | logger.addHandler(logging.NullHandler()) 22 | 23 | error_context = ContextStore("honeybadger_error_context") 24 | event_context = ContextStore("honeybadger_event_context") 25 | 26 | 27 | class Honeybadger(object): 28 | TS_FORMAT = "%Y-%m-%dT%H:%M:%S.%fZ" 29 | 30 | def __init__(self): 31 | error_context.clear() 32 | event_context.clear() 33 | 34 | self.config = Configuration() 35 | self.events_worker = EventsWorker( 36 | self._connection(), self.config, logger=logging.getLogger("honeybadger") 37 | ) 38 | atexit.register(self.shutdown) 39 | 40 | def _send_notice(self, notice): 41 | if callable(self.config.before_notify): 42 | try: 43 | notice = self.config.before_notify(notice) 44 | except Exception as e: 45 | logger.error("Error in before_notify callback: %s", e) 46 | 47 | if not isinstance(notice, Notice): 48 | logger.debug("Notice was filtered out by before_notify callback") 49 | return 50 | 51 | if notice.excluded_exception(): 52 | logger.debug("Notice was excluded by exception filter") 53 | return 54 | 55 | self._connection().send_notice(self.config, notice) 56 | 57 | def begin_request(self, _): 58 | error_context.clear() 59 | event_context.clear() 60 | 61 | def wrap_excepthook(self, func): 62 | self.existing_except_hook = func 63 | sys.excepthook = self.exception_hook 64 | 65 | def exception_hook(self, type, exception, exc_traceback): 66 | self.notify(exception=exception) 67 | self.existing_except_hook(type, exception, exc_traceback) 68 | 69 | def shutdown(self): 70 | self.events_worker.shutdown() 71 | 72 | def notify( 73 | self, 74 | exception=None, 75 | error_class=None, 76 | error_message=None, 77 | context: Optional[Dict[str, Any]] = None, 78 | fingerprint=None, 79 | tags: Optional[List[str]] = None, 80 | ): 81 | base = error_context.get() 82 | tag_ctx = base.pop("_tags", []) 83 | merged_ctx = {**base, **(context or {})} 84 | merged_tags = list({*tag_ctx, *(tags or [])}) 85 | 86 | request_id = self._get_event_context().get("request_id", None) 87 | 88 | notice = Notice( 89 | exception=exception, 90 | error_class=error_class, 91 | error_message=error_message, 92 | context=merged_ctx, 93 | fingerprint=fingerprint, 94 | tags=merged_tags, 95 | config=self.config, 96 | request_id=request_id, 97 | ) 98 | return self._send_notice(notice) 99 | 100 | def event(self, event_type=None, data=None, **kwargs): 101 | """ 102 | Send an event to Honeybadger. 103 | Events logged with this method will appear in Honeybadger Insights. 104 | """ 105 | # If the first argument is a string, treat it as event_type 106 | if isinstance(event_type, str): 107 | payload = data.copy() if data else {} 108 | payload["event_type"] = event_type 109 | # If the first argument is a dictionary, merge it with kwargs 110 | elif isinstance(event_type, dict): 111 | payload = event_type.copy() 112 | payload.update(kwargs) 113 | # Raise an error if event_type is not provided correctly 114 | else: 115 | raise ValueError( 116 | "The first argument must be either a string or a dictionary" 117 | ) 118 | 119 | if callable(self.config.before_event): 120 | try: 121 | next_payload = self.config.before_event(payload) 122 | if next_payload is False: 123 | return # Skip sending the event 124 | elif next_payload is not payload and next_payload is not None: 125 | payload = next_payload # Overwrite payload 126 | # else: assume in-place mutation; keep payload as-is 127 | except Exception as e: 128 | logger.error("Error in before_event callback: %s", e) 129 | 130 | # Add a timestamp to the payload if not provided 131 | if "ts" not in payload: 132 | payload["ts"] = datetime.datetime.now(datetime.timezone.utc) 133 | if isinstance(payload["ts"], datetime.datetime): 134 | payload["ts"] = payload["ts"].strftime(self.TS_FORMAT) 135 | 136 | final_payload = {**self._get_event_context(), **payload} 137 | 138 | # Check sampling on the final merged payload 139 | if not self._should_sample_event(final_payload): 140 | return 141 | 142 | # Strip internal _hb metadata before sending 143 | final_payload.pop("_hb", None) 144 | 145 | return self.events_worker.push(final_payload) 146 | 147 | def configure(self, **kwargs): 148 | self.config.set_config_from_dict(kwargs) 149 | self.auto_discover_plugins() 150 | 151 | # Update events worker with new config 152 | self.events_worker.connection = self._connection() 153 | self.events_worker.config = self.config 154 | 155 | def auto_discover_plugins(self): 156 | # Avoiding circular import error 157 | from honeybadger import contrib 158 | 159 | if self.config.is_aws_lambda_environment: 160 | default_plugin_manager.register(contrib.AWSLambdaPlugin()) 161 | 162 | def _should_sample_event(self, payload): 163 | """ 164 | Determine if an event should be sampled based on sample rate and payload metadata. 165 | Returns True if the event should be sent, False if it should be skipped. 166 | """ 167 | # Get sample rate from payload _hb override or global config 168 | hb_metadata = payload.get("_hb", {}) 169 | sample_rate = hb_metadata.get("sample_rate", self.config.events_sample_rate) 170 | 171 | if sample_rate >= 100: 172 | return True 173 | 174 | if sample_rate <= 0: 175 | return False 176 | 177 | sampling_key = payload.get("request_id") 178 | if not sampling_key: 179 | sampling_key = str(uuid.uuid4()) 180 | hash_value = int(hashlib.md5(sampling_key.encode()).hexdigest(), 16) 181 | return (hash_value % 100) < sample_rate 182 | 183 | # Error context 184 | # 185 | def _get_context(self): 186 | return error_context.get() 187 | 188 | def set_context(self, ctx: Optional[Dict[str, Any]] = None, **kwargs): 189 | error_context.update(ctx, **kwargs) 190 | 191 | def reset_context(self): 192 | error_context.clear() 193 | 194 | @contextmanager 195 | def context(self, ctx: Optional[Dict[str, Any]] = None, **kwargs): 196 | with error_context.override(ctx, **kwargs): 197 | yield 198 | 199 | # Event context 200 | # 201 | def _get_event_context(self): 202 | return event_context.get() 203 | 204 | def set_event_context(self, ctx: Optional[Dict[str, Any]] = None, **kwargs): 205 | event_context.update(ctx, **kwargs) 206 | 207 | def reset_event_context(self): 208 | event_context.clear() 209 | 210 | @contextmanager 211 | def event_context(self, ctx: Optional[Dict[str, Any]] = None, **kwargs): 212 | with event_context.override(ctx, **kwargs): 213 | yield 214 | 215 | def _connection(self): 216 | if self.config.is_dev() and not self.config.force_report_data: 217 | return fake_connection 218 | else: 219 | return connection 220 | -------------------------------------------------------------------------------- /honeybadger/contrib/aws_lambda.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | from typing import Any, Callable, Dict, Optional, TypeVar, cast 4 | 5 | from honeybadger import honeybadger 6 | from honeybadger.plugins import Plugin 7 | from honeybadger.utils import filter_dict 8 | 9 | from threading import local 10 | 11 | import logging 12 | 13 | logger = logging.getLogger(__name__) 14 | 15 | 16 | _thread_locals = local() 17 | REQUEST_LOCAL_KEY = "__awslambda_current_request" 18 | 19 | 20 | def current_event(): 21 | """ 22 | Return current execution event for this thread. 23 | """ 24 | return getattr(_thread_locals, REQUEST_LOCAL_KEY, None) 25 | 26 | 27 | def set_event(aws_event): 28 | """ 29 | Set current execution event for this thread. 30 | """ 31 | 32 | setattr(_thread_locals, REQUEST_LOCAL_KEY, aws_event) 33 | 34 | 35 | def clear_event(): 36 | """ 37 | Clears execution event for this thread. 38 | """ 39 | if hasattr(_thread_locals, REQUEST_LOCAL_KEY): 40 | setattr(_thread_locals, REQUEST_LOCAL_KEY, None) 41 | 42 | 43 | def reraise(tp, value, tb=None): 44 | """ 45 | Re-raises a caught error 46 | """ 47 | assert value is not None 48 | if value.__traceback__ is not tb: 49 | raise value.with_traceback(tb) 50 | raise value 51 | 52 | 53 | def get_lambda_bootstrap(): 54 | """ 55 | Get AWS Lambda bootstrap module 56 | 57 | First, we check for the presence of the bootstrap module in sys.modules. 58 | If it's not there, we check for the presence of __main__. 59 | In 3.8, the bootstrap module is imported as __main__. 60 | In 3.9, the bootstrap module is imported as __main__.awslambdaricmain. 61 | In some other cases, the bootstrap module is imported as __main__.bootstrap. 62 | """ 63 | if "bootstrap" in sys.modules: 64 | return sys.modules["bootstrap"] 65 | elif "__main__" in sys.modules: 66 | module = sys.modules["__main__"] 67 | # pylint: disable=no-member 68 | if hasattr(module, "awslambdaricmain") and hasattr( 69 | module.awslambdaricmain, "bootstrap" 70 | ): 71 | return module.awslambdaricmain.bootstrap 72 | elif hasattr(module, "bootstrap"): 73 | return module.bootstrap 74 | # pylint: enable=no-member 75 | 76 | return module 77 | else: 78 | return None 79 | 80 | 81 | # Define a type variable for handler functions 82 | HandlerType = TypeVar("HandlerType", bound=Callable[..., Any]) 83 | 84 | 85 | def _wrap_lambda_handler(handler: HandlerType) -> HandlerType: 86 | """ 87 | Wrap the lambda handler to catch exceptions and report to Honeybadger 88 | """ 89 | 90 | def wrapped_handler(aws_event, aws_context, *args, **kwargs): 91 | set_event(aws_event) 92 | 93 | honeybadger.begin_request(aws_event) 94 | try: 95 | return handler(aws_event, aws_context, *args, **kwargs) 96 | except Exception as e: 97 | honeybadger.notify(e) 98 | exc_info = sys.exc_info() 99 | clear_event() 100 | honeybadger.reset_context() 101 | 102 | # Rerase exception to proceed with normal aws error handling 103 | reraise(*exc_info) 104 | finally: 105 | # Ensure cleanup happens even if no exception occurs 106 | clear_event() 107 | honeybadger.reset_context() 108 | 109 | return cast(HandlerType, wrapped_handler) 110 | 111 | 112 | class AWSLambdaPlugin(Plugin): 113 | 114 | def __init__(self): 115 | super(AWSLambdaPlugin, self).__init__("AWSLambda") 116 | lambda_bootstrap = get_lambda_bootstrap() 117 | if not lambda_bootstrap: 118 | logger.warning( 119 | "Lambda function not wrapped by honeybadger: Unable to locate bootstrap module." 120 | ) 121 | self.initialize_request_handler(lambda_bootstrap) 122 | 123 | def supports(self, config, context): 124 | return config.is_aws_lambda_environment 125 | 126 | def generate_payload(self, default_payload, config, context): 127 | """ 128 | Generate payload by checking the lambda's 129 | request event 130 | """ 131 | request_payload = {"params": {"event": current_event()}, "context": context} 132 | default_payload["request"].update( 133 | filter_dict(request_payload, config.params_filters) 134 | ) 135 | 136 | AWS_ENV_MAP = ( 137 | ("_HANDLER", "handler"), 138 | ("AWS_REGION", "region"), 139 | ("AWS_EXECUTION_ENV", "runtime"), 140 | ("AWS_LAMBDA_FUNCTION_NAME", "function"), 141 | ("AWS_LAMBDA_FUNCTION_MEMORY_SIZE", "memory"), 142 | ("AWS_LAMBDA_FUNCTION_VERSION", "version"), 143 | ("AWS_LAMBDA_LOG_GROUP_NAME", "log_group"), 144 | ("AWS_LAMBDA_LOG_STREAM_NAME", "log_name"), 145 | ) 146 | 147 | lambda_details = { 148 | detail[1]: os.environ.get(detail[0], None) for detail in AWS_ENV_MAP 149 | } 150 | default_payload["details"] = {} 151 | default_payload["details"]["Lambda Details"] = lambda_details 152 | default_payload["request"]["component"] = lambda_details["function"] 153 | default_payload["request"]["action"] = lambda_details["handler"] 154 | trace_id = os.environ.get("_X_AMZN_TRACE_ID", None) 155 | if trace_id: 156 | default_payload["request"]["context"]["lambda_trace_id"] = trace_id 157 | 158 | return default_payload 159 | 160 | def initialize_request_handler(self, lambda_bootstrap): 161 | """ 162 | Here we fetch the http & event handler from the lambda bootstrap module 163 | and override it with a wrapped version 164 | """ 165 | if lambda_bootstrap is None: 166 | return 167 | 168 | # Pre Python 3.7 handling 169 | if hasattr(lambda_bootstrap, "handle_http_request"): 170 | try: 171 | # Get original handlers 172 | original_event_handler = lambda_bootstrap.handle_event_request 173 | original_http_handler = lambda_bootstrap.handle_http_request 174 | 175 | # Define event handler wrapper for pre-3.7 176 | def pre37_event_handler(request_handler, *args, **kwargs): 177 | wrapped_handler = _wrap_lambda_handler(request_handler) 178 | return original_event_handler(wrapped_handler, *args, **kwargs) 179 | 180 | # Define HTTP handler wrapper for pre-3.7 181 | def pre37_http_handler(request_handler, *args, **kwargs): 182 | wrapped_handler = _wrap_lambda_handler(request_handler) 183 | return original_http_handler(wrapped_handler, *args, **kwargs) 184 | 185 | # Replace the original handlers 186 | lambda_bootstrap.handle_event_request = pre37_event_handler 187 | lambda_bootstrap.handle_http_request = pre37_http_handler 188 | 189 | except AttributeError as e: 190 | # Fail safely if we can't monkeypatch lambda handler 191 | logger.warning("Lambda function not wrapped by honeybadger: %s" % e) 192 | 193 | # Python 3.7+ handling 194 | else: 195 | try: 196 | original_event_handler = lambda_bootstrap.handle_event_request 197 | 198 | # Define event handler wrapper for 3.7+ 199 | def post37_event_handler( 200 | lambda_runtime_client, request_handler, *args, **kwargs 201 | ): 202 | wrapped_handler = _wrap_lambda_handler(request_handler) 203 | return original_event_handler( 204 | lambda_runtime_client, wrapped_handler, *args, **kwargs 205 | ) 206 | 207 | # Replace the original handler 208 | lambda_bootstrap.handle_event_request = post37_event_handler 209 | 210 | except AttributeError as e: 211 | # Future lambda runtime may change execution strategy yet again 212 | # Third party lambda services (such as zappa) may override function execution 213 | logger.warning("Lambda function not wrapped by honeybadger: %s" % e) 214 | -------------------------------------------------------------------------------- /honeybadger/contrib/django.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | import re 3 | import time 4 | import uuid 5 | 6 | from six import iteritems 7 | 8 | from honeybadger import honeybadger 9 | from honeybadger.plugins import Plugin, default_plugin_manager 10 | from honeybadger.utils import ( 11 | filter_dict, 12 | filter_env_vars, 13 | get_duration, 14 | sanitize_request_id, 15 | ) 16 | from honeybadger.contrib.db import DBHoneybadger 17 | 18 | try: 19 | from threading import local # type: ignore[no-redef] 20 | except ImportError: 21 | from django.utils._threading_local import local # type: ignore[no-redef,import] 22 | 23 | _thread_locals = local() 24 | 25 | REQUEST_LOCAL_KEY = "__django_current_request" 26 | 27 | 28 | def current_request(): 29 | """ 30 | Return current request for this thread. 31 | :return: current request for this thread. 32 | """ 33 | return getattr(_thread_locals, REQUEST_LOCAL_KEY, None) 34 | 35 | 36 | def set_request(request): 37 | """ 38 | Set request for current thread. 39 | :param request: current request. 40 | """ 41 | setattr(_thread_locals, REQUEST_LOCAL_KEY, request) 42 | 43 | 44 | def clear_request(): 45 | """ 46 | Clears request for this thread. 47 | """ 48 | if hasattr(_thread_locals, REQUEST_LOCAL_KEY): 49 | setattr(_thread_locals, REQUEST_LOCAL_KEY, None) 50 | 51 | 52 | class DjangoPlugin(Plugin): 53 | """ 54 | Plugin for generating payload from Django requests. 55 | """ 56 | 57 | def __init__(self): 58 | super(DjangoPlugin, self).__init__("Django") 59 | 60 | def supports(self, config, context): 61 | """ 62 | Check whether this is a django request or not. 63 | :param config: honeybadger configuration. 64 | :param context: current honeybadger configuration. 65 | :return: True if this is a django request, False else. 66 | """ 67 | request = current_request() 68 | return request is not None and re.match(r"^django\.", request.__module__) 69 | 70 | def generate_payload(self, default_payload, config, context): 71 | """ 72 | Generate payload by checking Django request object. 73 | :param context: current context. 74 | :param config: honeybadger configuration. 75 | :return: a dict with the generated payload. 76 | """ 77 | import django 78 | 79 | if django.VERSION[0] < 2: 80 | # pylint: disable-next=import-error,no-name-in-module 81 | from django.core.urlresolvers import resolve # type: ignore[import] 82 | else: 83 | from django.urls import resolve 84 | 85 | request = current_request() 86 | resolver_match = request.resolver_match or resolve(request.path_info) 87 | request_payload = { 88 | "url": request.build_absolute_uri(), 89 | "component": resolver_match.app_name, 90 | "action": resolver_match.func.__name__, 91 | "params": {}, 92 | "session": {}, 93 | "cgi_data": filter_dict( 94 | filter_env_vars(request.META), config.params_filters 95 | ), 96 | "context": context, 97 | } 98 | 99 | if hasattr(request, "session"): 100 | request_payload["session"] = filter_dict( 101 | dict(request.session), config.params_filters 102 | ) 103 | 104 | if hasattr(request, "COOKIES"): 105 | request_payload["cgi_data"]["HTTP_COOKIE"] = filter_dict( 106 | request.COOKIES, config.params_filters 107 | ) 108 | 109 | if request.method == "GET": 110 | request_payload["params"] = filter_dict( 111 | dict(request.GET), config.params_filters 112 | ) 113 | 114 | else: 115 | request_payload["params"] = filter_dict( 116 | dict(request.POST), config.params_filters 117 | ) 118 | 119 | default_payload["request"].update(request_payload) 120 | 121 | return default_payload 122 | 123 | 124 | class DjangoHoneybadgerMiddleware(object): 125 | def __init__(self, get_response=None): 126 | self.get_response = get_response 127 | from django.conf import settings 128 | 129 | if getattr(settings, "DEBUG"): 130 | honeybadger.configure(environment="development") 131 | config_kwargs = dict( 132 | [ 133 | (k.lower(), v) 134 | for (k, v) in iteritems(getattr(settings, "HONEYBADGER", {})) 135 | ] 136 | ) 137 | honeybadger.configure(**config_kwargs) 138 | honeybadger.config.set_12factor_config() # environment should override Django settings 139 | default_plugin_manager.register(DjangoPlugin()) 140 | if honeybadger.config.insights_enabled: 141 | self._patch_cursor() 142 | 143 | def __call__(self, request): 144 | set_request(request) 145 | start_time = time.time() 146 | honeybadger.begin_request(request) 147 | self._set_request_id(request) 148 | response = self.get_response(request) 149 | 150 | if ( 151 | honeybadger.config.insights_enabled 152 | and not honeybadger.config.insights_config.django.disabled 153 | ): 154 | self._send_request_event(request, response, start_time) 155 | 156 | honeybadger.reset_context() 157 | clear_request() 158 | 159 | return response 160 | 161 | def _set_request_id(self, request): 162 | # Attempt to get request ID from various sources 163 | request_id = ( 164 | getattr(request, "id", None) 165 | or getattr(request, "request_id", None) 166 | or request.headers.get("X-Request-ID", None) 167 | ) 168 | request_id = sanitize_request_id(request_id) 169 | if not request_id: 170 | request_id = str(uuid.uuid4()) 171 | 172 | honeybadger.set_event_context(request_id=request_id) 173 | 174 | def _patch_cursor(self): 175 | from django.db.backends.utils import CursorWrapper 176 | 177 | orig_exec = CursorWrapper.execute 178 | CursorWrapper.execute = DBHoneybadger.django_execute(orig_exec) 179 | 180 | def _send_request_event(self, request, response, start_time): 181 | # Get resolver data 182 | resolver_match = getattr(request, "resolver_match", None) 183 | view_name = None 184 | view_module = None 185 | app_name = None 186 | 187 | if resolver_match: 188 | view_name = getattr(resolver_match, "view_name", None) 189 | if not view_name and hasattr(resolver_match, "func"): 190 | view_name = resolver_match.func.__name__ 191 | 192 | if hasattr(resolver_match, "func"): 193 | view_module = resolver_match.func.__module__ 194 | 195 | app_name = getattr(resolver_match, "app_name", None) 196 | 197 | request_data = { 198 | "path": request.path_info if hasattr(request, "path_info") else None, 199 | "method": request.method if hasattr(request, "method") else None, 200 | "status": ( 201 | response.status_code if hasattr(response, "status_code") else None 202 | ), 203 | "view": view_name, 204 | "module": view_module, 205 | "app": app_name, 206 | "duration": get_duration(start_time), 207 | } 208 | 209 | if honeybadger.config.insights_config.django.include_params: 210 | params = {} 211 | for qd in [request.GET, request.POST]: 212 | for key in qd: 213 | values = qd.getlist(key) 214 | params[key] = values[0] if len(values) == 1 else values 215 | filtered = filter_dict( 216 | params, honeybadger.config.params_filters, remove_keys=True 217 | ) 218 | request_data["params"] = filtered 219 | 220 | honeybadger.event("django.request", request_data) 221 | 222 | def process_exception(self, request, exception): 223 | self.__set_user_from_context(request) 224 | honeybadger.notify(exception) 225 | clear_request() 226 | return None 227 | 228 | def __set_user_from_context(self, request): 229 | # in Django 1 request.user.is_authenticated is a function, in Django 2+ it's a boolean 230 | if hasattr(request, "user") and ( 231 | ( 232 | isinstance(request.user.is_authenticated, bool) 233 | and request.user.is_authenticated 234 | ) 235 | or ( 236 | callable(request.user.is_authenticated) 237 | and request.user.is_authenticated() 238 | ) 239 | ): 240 | honeybadger.set_context(username=request.user.get_username()) 241 | honeybadger.set_context(user_id=request.user.id) 242 | -------------------------------------------------------------------------------- /honeybadger/tests/test_payload.py: -------------------------------------------------------------------------------- 1 | from six.moves import range 2 | from six.moves import zip 3 | from contextlib import contextmanager 4 | import os 5 | import sys 6 | 7 | from honeybadger.payload import ( 8 | create_payload, 9 | error_payload, 10 | server_payload, 11 | MAX_CAUSE_DEPTH, 12 | ) 13 | from honeybadger.config import Configuration 14 | 15 | from mock import patch 16 | import pytest 17 | 18 | # TODO: figure out how to run Django tests? 19 | 20 | 21 | @contextmanager 22 | def mock_traceback(method="traceback.extract_stack", line_no=5): 23 | with patch(method) as traceback_mock: 24 | path = os.path.dirname(__file__) 25 | tb_data = [] 26 | for i in range(1, 3): 27 | tb_data.append( 28 | ( 29 | os.path.join(path, "file_{}.py".format(i)), 30 | line_no * i, 31 | "method_{}".format(i), 32 | ) 33 | ) 34 | 35 | tb_data.append(("/fake/path/fake_file.py", 15, "fake_method")) 36 | tb_data.append( 37 | (os.path.join(path, "payload_fixture.txt"), line_no, "fixture_method") 38 | ) 39 | 40 | traceback_mock.return_value = tb_data 41 | yield traceback_mock 42 | 43 | 44 | def test_error_payload_project_root_replacement(): 45 | with mock_traceback() as traceback_mock: 46 | config = Configuration(project_root=os.path.dirname(__file__)) 47 | payload = error_payload( 48 | dict(error_class="Exception", error_message="Test"), None, config 49 | ) 50 | 51 | assert traceback_mock.call_count == 1 52 | assert payload["backtrace"][0]["file"].startswith("[PROJECT_ROOT]") 53 | assert payload["backtrace"][1]["file"] == "/fake/path/fake_file.py" 54 | 55 | 56 | def test_error_payload_source_line_top_of_file(): 57 | with mock_traceback(line_no=1) as traceback_mock: 58 | config = Configuration() 59 | payload = error_payload( 60 | dict(error_class="Exception", error_message="Test"), None, config 61 | ) 62 | expected = dict(zip(range(1, 5), ["Line {}\n".format(x) for x in range(1, 5)])) 63 | assert traceback_mock.call_count == 1 64 | assert payload["backtrace"][0]["source"] == expected 65 | 66 | 67 | def test_error_payload_source_line_bottom_of_file(): 68 | with mock_traceback(line_no=10) as traceback_mock: 69 | config = Configuration() 70 | payload = error_payload( 71 | dict(error_class="Exception", error_message="Test"), None, config 72 | ) 73 | expected = dict( 74 | zip(range(7, 11), ["Line {}\n".format(x) for x in range(7, 11)]) 75 | ) 76 | assert traceback_mock.call_count == 1 77 | assert payload["backtrace"][0]["source"] == expected 78 | 79 | 80 | def test_error_payload_source_line_midfile(): 81 | with mock_traceback(line_no=5) as traceback_mock: 82 | config = Configuration() 83 | payload = error_payload( 84 | dict(error_class="Exception", error_message="Test"), None, config 85 | ) 86 | expected = dict(zip(range(2, 9), ["Line {}\n".format(x) for x in range(2, 9)])) 87 | assert traceback_mock.call_count == 1 88 | assert payload["backtrace"][0]["source"] == expected 89 | 90 | 91 | @patch("os.path.isfile", return_value=False) 92 | def test_error_payload_source_missing_file(_isfile): 93 | with mock_traceback(line_no=5) as traceback_mock: 94 | config = Configuration() 95 | payload = error_payload( 96 | dict(error_class="Exception", error_message="Test"), None, config 97 | ) 98 | assert payload["backtrace"][0]["source"] == {} 99 | 100 | 101 | def test_payload_with_no_exception_cause(): 102 | with mock_traceback() as traceback_mock: 103 | config = Configuration() 104 | exception = Exception("Test") 105 | 106 | payload = error_payload(exc_traceback=None, exception=exception, config=config) 107 | assert len(payload["causes"]) == 0 108 | 109 | 110 | def test_payload_captures_exception_cause(): 111 | with mock_traceback() as traceback_mock: 112 | config = Configuration() 113 | exception = Exception("Test") 114 | exception.__cause__ = Exception("Exception cause") 115 | 116 | payload = error_payload(exc_traceback=None, exception=exception, config=config) 117 | assert len(payload["causes"]) == 1 118 | 119 | 120 | def test_payload_captures_short_exception_cause_chain(): 121 | with mock_traceback() as traceback_mock: 122 | config = Configuration() 123 | exception = Exception("Inner test") 124 | innerException = Exception("Inner exception") 125 | innerException.__cause__ = Exception("Inner cause") 126 | exception.__cause__ = innerException 127 | 128 | payload = error_payload(exc_traceback=None, exception=exception, config=config) 129 | assert len(payload["causes"]) == 2 130 | 131 | 132 | def test_payload_captures_circular_exception_cause_chain(): 133 | with mock_traceback() as traceback_mock: 134 | config = Configuration() 135 | exceptionA = Exception("A") 136 | exceptionB = Exception("B") 137 | exceptionA.__cause__ = exceptionB 138 | exceptionB.__cause__ = exceptionA 139 | 140 | payload = error_payload(exc_traceback=None, exception=exceptionA, config=config) 141 | assert len(payload["causes"]) == MAX_CAUSE_DEPTH + 1 142 | assert ( 143 | payload["causes"][-1]["message"] 144 | == f"Exception cause chain truncated after {MAX_CAUSE_DEPTH} levels. Possible circular reference." 145 | ) 146 | 147 | 148 | def test_payload_captures_deep_exception_cause_chain(): 149 | with mock_traceback() as traceback_mock: 150 | config = Configuration() 151 | root = Exception("root") 152 | current = root 153 | for i in range(MAX_CAUSE_DEPTH * 2): 154 | e = Exception(i) 155 | e.__cause__ = current 156 | current = e 157 | 158 | payload = error_payload(exc_traceback=None, exception=current, config=config) 159 | assert len(payload["causes"]) == MAX_CAUSE_DEPTH + 1 160 | assert ( 161 | payload["causes"][-1]["message"] 162 | == f"Exception cause chain truncated after {MAX_CAUSE_DEPTH} levels. Possible circular reference." 163 | ) 164 | 165 | 166 | def test_error_payload_with_nested_exception(): 167 | with mock_traceback() as traceback_mock: 168 | config = Configuration() 169 | exception = Exception("Test") 170 | exception_cause = Exception("Exception cause") 171 | exception_cause.__cause__ = Exception("Nested") 172 | exception.__cause__ = exception_cause 173 | payload = error_payload(exc_traceback=None, exception=exception, config=config) 174 | assert len(payload["causes"]) == 2 175 | 176 | 177 | def test_error_payload_with_fingerprint(): 178 | config = Configuration() 179 | exception = Exception("Test") 180 | payload = error_payload( 181 | exception, exc_traceback=None, config=config, fingerprint="a fingerprint" 182 | ) 183 | assert payload["fingerprint"] == "a fingerprint" 184 | 185 | 186 | def test_error_payload_with_fingerprint_as_type(): 187 | config = Configuration() 188 | exception = Exception("Test") 189 | payload = error_payload( 190 | exception, exc_traceback=None, config=config, fingerprint={"a": 1, "b": 2} 191 | ) 192 | assert payload["fingerprint"] == "{'a': 1, 'b': 2}" 193 | 194 | 195 | def test_error_payload_without_fingerprint(): 196 | config = Configuration() 197 | exception = Exception("Test") 198 | payload = error_payload(exception, exc_traceback=None, config=config) 199 | assert payload.get("fingerprint") == None 200 | 201 | 202 | def test_server_payload(): 203 | config = Configuration( 204 | project_root=os.path.dirname(__file__), 205 | environment="test", 206 | hostname="test.local", 207 | ) 208 | payload = server_payload(config) 209 | 210 | assert payload["project_root"] == os.path.dirname(__file__) 211 | assert payload["environment_name"] == "test" 212 | assert payload["hostname"] == "test.local" 213 | assert payload["pid"] == os.getpid() 214 | assert type(payload["stats"]["mem"]["total"]) == float 215 | assert type(payload["stats"]["mem"]["free"]) == float 216 | 217 | 218 | def test_psutil_is_optional(): 219 | config = Configuration() 220 | 221 | with patch.dict(sys.modules, {"psutil": None}): 222 | payload = server_payload(config) 223 | assert payload["stats"] == {} 224 | 225 | 226 | def test_create_payload_without_local_variables(): 227 | config = Configuration() 228 | exception = Exception("Test") 229 | payload = create_payload(exception, config=config) 230 | assert payload["request"].get("local_variables") == None 231 | 232 | 233 | def test_create_payload_with_local_variables(): 234 | config = Configuration(report_local_variables=True) 235 | with pytest.raises(Exception): 236 | test_local_variable = {"test": "var"} 237 | exception = Exception("Test") 238 | payload = create_payload(exception, config=config) 239 | assert payload["request"]["local_variables"] == test_local_variable 240 | 241 | 242 | def test_create_payload_with_correlation_context(): 243 | config = Configuration() 244 | exception = Exception("Test") 245 | request_id = "12345" 246 | payload = create_payload( 247 | exception, config=config, correlation_context={"request_id": request_id} 248 | ) 249 | assert payload["correlation_context"]["request_id"] == request_id 250 | -------------------------------------------------------------------------------- /honeybadger/tests/test_events_worker.py: -------------------------------------------------------------------------------- 1 | import time 2 | from types import SimpleNamespace 3 | import pytest 4 | from honeybadger.events_worker import EventsWorker, EventsSendResult, Event 5 | from honeybadger.types import EventsSendStatus 6 | 7 | 8 | class DummyConnection: 9 | """Stub with configurable behavior for send_events.""" 10 | 11 | def __init__(self, behaviors=None): 12 | self.behaviors = behaviors or [] 13 | self.call_count = 0 14 | self.batches = [] 15 | 16 | def send_events(self, cfg, batch: Event) -> EventsSendResult: 17 | self.batches.append(batch) 18 | if self.call_count < len(self.behaviors): 19 | result = self.behaviors[self.call_count] 20 | else: 21 | result = EventsSendResult(EventsSendStatus.OK) 22 | self.call_count += 1 23 | return result 24 | 25 | 26 | @pytest.fixture 27 | def base_config(): 28 | return SimpleNamespace( 29 | api_key="key", 30 | endpoint="url", 31 | environment="env", 32 | events_batch_size=3, 33 | events_max_queue_size=10, 34 | events_timeout=0.1, 35 | events_max_batch_retries=2, 36 | events_throttle_wait=0.1, 37 | ) 38 | 39 | 40 | @pytest.fixture 41 | def worker(base_config): 42 | conn = DummyConnection() 43 | w = EventsWorker(connection=conn, config=base_config) 44 | yield w, conn 45 | w.shutdown() 46 | 47 | 48 | def wait_for(predicate, timeout): 49 | end = time.time() + timeout 50 | while time.time() < end: 51 | if predicate(): 52 | return True 53 | time.sleep(0.005) 54 | return False 55 | 56 | 57 | def test_batch_send_on_batch_size(worker): 58 | w, conn = worker 59 | events = [{"id": i} for i in (1, 2, 3)] 60 | for e in events: 61 | assert w.push(e) 62 | time.sleep(0.05) 63 | assert conn.batches == [events] 64 | 65 | 66 | def test_no_send_under_batch_size(worker): 67 | w, conn = worker 68 | for e in ({"id": 1}, {"id": 2}): 69 | assert w.push(e) 70 | time.sleep(0.05) 71 | assert conn.batches == [] 72 | 73 | 74 | def test_drop_events_when_queue_full(base_config): 75 | cfg = SimpleNamespace(**vars(base_config)) 76 | cfg.events_max_queue_size = 4 77 | conn = DummyConnection() 78 | w = EventsWorker(connection=conn, config=cfg) 79 | dropped = 0 80 | for i in range(6): 81 | if not w.push({"id": i + 1}): 82 | dropped += 1 83 | stats = w.get_stats() 84 | assert dropped == 2 85 | assert stats["dropped_events"] == 2 86 | assert stats["queue_size"] == 4 87 | w.shutdown() 88 | 89 | 90 | def test_flush_on_timeout(base_config): 91 | cfg = SimpleNamespace(**vars(base_config)) 92 | cfg.events_batch_size = 10 93 | cfg.events_timeout = 0.05 94 | conn = DummyConnection() 95 | w = EventsWorker(connection=conn, config=cfg) 96 | for e in ({"id": 1}, {"id": 2}): 97 | w.push(e) 98 | time.sleep(cfg.events_timeout + 0.05) 99 | assert conn.batches == [[{"id": 1}, {"id": 2}]] 100 | w.shutdown() 101 | 102 | 103 | def test_reset_timer_after_send(base_config): 104 | cfg = SimpleNamespace(**vars(base_config)) 105 | cfg.events_timeout = 0.05 106 | conn = DummyConnection() 107 | w = EventsWorker(connection=conn, config=cfg) 108 | first = [{"id": 1}, {"id": 2}, {"id": 3}] 109 | for e in first: 110 | w.push(e) 111 | assert wait_for(lambda: len(conn.batches) >= 1, cfg.events_timeout + 0.02) 112 | assert conn.batches[0] == first 113 | second = [{"id": 4}, {"id": 5}] 114 | for e in second: 115 | w.push(e) 116 | time.sleep(cfg.events_timeout / 2) 117 | assert len(conn.batches) == 1 118 | time.sleep(cfg.events_timeout) 119 | assert conn.batches[1] == second 120 | w.shutdown() 121 | 122 | 123 | def test_retry_and_drop_after_max_retries(base_config): 124 | cfg = SimpleNamespace(**vars(base_config)) 125 | cfg.events_batch_size = 2 126 | cfg.events_timeout = 0.05 127 | cfg.events_max_batch_retries = 3 128 | behaviors = [ 129 | EventsSendResult(EventsSendStatus.ERROR, "fail") 130 | ] * cfg.events_max_batch_retries 131 | conn = DummyConnection(behaviors=behaviors) 132 | w = EventsWorker(connection=conn, config=cfg) 133 | for e in ({"id": 1}, {"id": 2}): 134 | w.push(e) 135 | time.sleep(cfg.events_timeout * (cfg.events_max_batch_retries + 1)) 136 | assert len(conn.batches) == cfg.events_max_batch_retries 137 | assert w.get_stats()["batch_count"] == 0 138 | w.shutdown() 139 | 140 | 141 | def test_queue_new_events_during_retries(base_config): 142 | cfg = SimpleNamespace(**vars(base_config)) 143 | cfg.events_batch_size = 2 144 | cfg.events_timeout = 0.05 145 | cfg.events_max_batch_retries = 2 146 | behaviors = [ 147 | EventsSendResult(EventsSendStatus.ERROR, "fail"), 148 | EventsSendResult(EventsSendStatus.OK), 149 | ] 150 | conn = DummyConnection(behaviors=behaviors) 151 | w = EventsWorker(connection=conn, config=cfg) 152 | first = [{"id": 1}, {"id": 2}] 153 | for e in first: 154 | w.push(e) 155 | time.sleep(0.01) 156 | second = [{"id": 3}, {"id": 4}] 157 | for e in second: 158 | w.push(e) 159 | time.sleep(cfg.events_timeout * 2) 160 | assert conn.batches[0] == first 161 | assert conn.batches[1] == first 162 | assert conn.batches[2] == second 163 | w.shutdown() 164 | 165 | 166 | def test_does_not_reset_timer_on_subsequent_pushes(base_config): 167 | cfg = SimpleNamespace(**vars(base_config)) 168 | cfg.events_batch_size = 100 169 | cfg.events_timeout = 0.1 170 | 171 | conn = DummyConnection() 172 | w = EventsWorker(connection=conn, config=cfg) 173 | 174 | w.push({"id": 1}) 175 | time.sleep(cfg.events_timeout * 0.4) 176 | w.push({"id": 2}) 177 | time.sleep(cfg.events_timeout * 0.4) 178 | w.push({"id": 3}) 179 | 180 | assert wait_for(lambda: len(conn.batches) >= 1, cfg.events_timeout * 1.1) 181 | assert conn.batches[0] == [{"id": 1}, {"id": 2}, {"id": 3}] 182 | 183 | w.shutdown() 184 | 185 | 186 | def test_pushes_after_flush(base_config): 187 | cfg = SimpleNamespace(**vars(base_config)) 188 | cfg.events_batch_size = 100 189 | cfg.events_timeout = 0.05 190 | conn = DummyConnection() 191 | w = EventsWorker(connection=conn, config=cfg) 192 | w.push({"id": 1}) 193 | time.sleep(0.06) 194 | assert conn.batches[0] == [{"id": 1}] 195 | w.push({"id": 2}) 196 | assert len(conn.batches) == 1 197 | time.sleep(cfg.events_timeout + 0.01) 198 | assert conn.batches[1] == [{"id": 2}] 199 | w.shutdown() 200 | 201 | 202 | def test_throttling_and_resume(base_config): 203 | cfg = SimpleNamespace(**vars(base_config)) 204 | cfg.events_batch_size = 2 205 | cfg.events_timeout = 0.05 206 | behaviors = [ 207 | EventsSendResult(EventsSendStatus.ERROR, "throttled"), 208 | EventsSendResult(EventsSendStatus.OK), 209 | EventsSendResult(EventsSendStatus.OK), 210 | ] 211 | conn = DummyConnection(behaviors=behaviors) 212 | w = EventsWorker(connection=conn, config=cfg) 213 | first = [{"id": 1}, {"id": 2}] 214 | for e in first: 215 | w.push(e) 216 | time.sleep(0.01) 217 | second = [{"id": 3}, {"id": 4}] 218 | for e in second: 219 | w.push(e) 220 | time.sleep(cfg.events_throttle_wait + cfg.events_timeout * 2) 221 | assert conn.batches[0] == first 222 | assert conn.batches[1] == first 223 | assert conn.batches[2] == second 224 | w.shutdown() 225 | 226 | 227 | def test_true_throttling_status_flips_throttled_flag_and_retries_fast(base_config): 228 | cfg = base_config 229 | cfg.events_timeout = 0.01 230 | cfg.events_throttle_wait = 0.01 231 | 232 | behaviors = [ 233 | EventsSendResult(EventsSendStatus.THROTTLING), 234 | EventsSendResult(EventsSendStatus.OK), 235 | ] 236 | conn = DummyConnection(behaviors=behaviors) 237 | w = EventsWorker(connection=conn, config=cfg) 238 | 239 | for i in range(cfg.events_batch_size): 240 | assert w.push({"id": i}) 241 | 242 | assert wait_for( 243 | lambda: conn.call_count >= 1, timeout=0.05 244 | ), f"first send never happened, call_count={conn.call_count}" 245 | assert w.get_stats()["throttling"] is True 246 | 247 | assert wait_for( 248 | lambda: conn.call_count >= 2, timeout=0.1 249 | ), f"retry never happened, call_count={conn.call_count}" 250 | assert w.get_stats()["throttling"] is False 251 | 252 | w.shutdown() 253 | 254 | 255 | def test_flush_delay_respects_throttle_wait(base_config): 256 | cfg = SimpleNamespace(**vars(base_config)) 257 | cfg.events_batch_size = 2 258 | cfg.events_timeout = 0.05 259 | cfg.events_throttle_wait = 0.15 260 | 261 | behaviors = [ 262 | EventsSendResult(EventsSendStatus.ERROR, "throttled"), 263 | EventsSendResult(EventsSendStatus.OK), 264 | ] 265 | conn = DummyConnection(behaviors=behaviors) 266 | w = EventsWorker(connection=conn, config=cfg) 267 | 268 | for e in ({"id": 1}, {"id": 2}): 269 | w.push(e) 270 | 271 | assert wait_for(lambda: len(conn.batches) >= 1, 0.1) 272 | assert wait_for(lambda: len(conn.batches) >= 2, cfg.events_throttle_wait + 0.1) 273 | assert conn.batches[1] == [{"id": 1}, {"id": 2}] 274 | w.shutdown() 275 | 276 | 277 | def test_interleave_new_events_during_throttle_backoff(base_config): 278 | cfg = base_config 279 | behaviors = [ 280 | EventsSendResult(EventsSendStatus.THROTTLING), 281 | EventsSendResult(EventsSendStatus.OK), 282 | ] 283 | conn = DummyConnection(behaviors=behaviors) 284 | w = EventsWorker(connection=conn, config=cfg) 285 | 286 | first = [{"id": 1}, {"id": 2}, {"id": 3}] 287 | for e in first: 288 | w.push(e) 289 | 290 | assert wait_for( 291 | lambda: len(conn.batches) >= 1, timeout=cfg.events_timeout * 1.1 292 | ), f"Expected first batch within {cfg.events_timeout}s" 293 | 294 | second = [{"id": 4}, {"id": 5}, {"id": 6}] 295 | for e in second: 296 | w.push(e) 297 | 298 | total_wait = cfg.events_throttle_wait + cfg.events_timeout * 2 299 | assert wait_for( 300 | lambda: len(conn.batches) >= 3, timeout=total_wait 301 | ), f"Expected 3 batches within {total_wait}s" 302 | 303 | assert conn.batches[0] == first 304 | assert conn.batches[1] == first 305 | assert conn.batches[2] == second 306 | 307 | w.shutdown() 308 | 309 | 310 | def test_send_remaining_on_shutdown(base_config): 311 | cfg = SimpleNamespace(**vars(base_config)) 312 | cfg.events_batch_size = 100 313 | cfg.events_timeout = 1.0 314 | conn = DummyConnection() 315 | w = EventsWorker(connection=conn, config=cfg) 316 | for e in ({"id": 1}, {"id": 2}): 317 | w.push(e) 318 | w.shutdown() 319 | assert conn.batches[-1] == [{"id": 1}, {"id": 2}] 320 | -------------------------------------------------------------------------------- /honeybadger/contrib/flask.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | from contextvars import ContextVar 3 | 4 | import logging 5 | import time 6 | import uuid 7 | 8 | from honeybadger import honeybadger 9 | from honeybadger.plugins import Plugin, default_plugin_manager 10 | from honeybadger.utils import ( 11 | filter_dict, 12 | filter_env_vars, 13 | get_duration, 14 | extract_honeybadger_config, 15 | sanitize_request_id, 16 | ) 17 | from honeybadger.contrib.db import DBHoneybadger 18 | from six import iteritems 19 | 20 | logger = logging.getLogger(__name__) 21 | 22 | _request_info: ContextVar[dict] = ContextVar("_request_info") 23 | 24 | 25 | class FlaskPlugin(Plugin): 26 | """ 27 | Handle flask plugin information. 28 | """ 29 | 30 | def __init__(self): 31 | super(FlaskPlugin, self).__init__("Flask") 32 | 33 | def supports(self, config, context): 34 | """ 35 | Check whether we are in a Flask request context. 36 | :param config: honeybadger configuration. 37 | :param context: current honeybadger configuration. 38 | :return: True if this is a django request, False else. 39 | """ 40 | try: 41 | from flask import request 42 | except ImportError: 43 | return False 44 | else: 45 | return bool(request) 46 | 47 | def generate_payload(self, default_payload, config, context): 48 | """ 49 | Generate payload by checking Flask request object. 50 | :param context: current context. 51 | :param config: honeybadger configuration. 52 | :return: a dict with the generated payload. 53 | """ 54 | from flask import current_app, session, request as _request 55 | 56 | current_view = current_app.view_functions[_request.endpoint] 57 | if hasattr(current_view, "view_class"): 58 | component = ".".join( 59 | (current_view.__module__, current_view.view_class.__name__) 60 | ) 61 | else: 62 | component = current_view.__module__ 63 | cgi_data = {k: v for k, v in iteritems(_request.headers)} 64 | cgi_data.update( 65 | {"REQUEST_METHOD": _request.method, "HTTP_COOKIE": dict(_request.cookies)} 66 | ) 67 | payload = { 68 | "url": _request.base_url, 69 | "component": component, 70 | "action": _request.endpoint, 71 | "params": {}, 72 | "session": filter_dict(dict(session), config.params_filters), 73 | "cgi_data": filter_dict(filter_env_vars(cgi_data), config.params_filters), 74 | "context": context, 75 | } 76 | 77 | # Add query params 78 | params = filter_dict(_request.args.to_dict(flat=False), config.params_filters) 79 | params.update( 80 | filter_dict(_request.form.to_dict(flat=False), config.params_filters) 81 | ) 82 | 83 | payload["params"] = params 84 | 85 | default_payload["request"].update(payload) 86 | 87 | return default_payload 88 | 89 | 90 | class FlaskHoneybadger(object): 91 | """ 92 | Flask extension for Honeybadger. Initializes Honeybadger and adds a request information to payload. 93 | """ 94 | 95 | def __init__( 96 | self, app=None, report_exceptions=False, reset_context_after_request=False 97 | ): 98 | """ 99 | Initialize Honeybadger. 100 | :param flask.Application app: the application to wrap for the exception. 101 | :param bool report_exceptions: whether to automatically report exceptions raised by Flask on requests 102 | (i.e. by calling abort) or not. 103 | :param bool reset_context_after_request: whether to reset honeybadger context after each request. 104 | """ 105 | self.app = app 106 | self.report_exceptions = False 107 | self.reset_context_after_request = False 108 | default_plugin_manager.register(FlaskPlugin()) 109 | 110 | if app is not None: 111 | self.init_app( 112 | app, 113 | report_exceptions=report_exceptions, 114 | reset_context_after_request=reset_context_after_request, 115 | ) 116 | 117 | def init_app(self, app, report_exceptions=False, reset_context_after_request=False): 118 | """ 119 | Initialize honeybadger and listen for errors. 120 | :param Flask app: the Flask application object. 121 | :param bool report_exceptions: whether to automatically report exceptions raised by Flask on requests 122 | (i.e. by calling abort) or not. 123 | :param bool reset_context_after_request: whether to reset honeybadger context after each request. 124 | """ 125 | from flask import ( 126 | request_tearing_down, 127 | got_request_exception, 128 | request_finished, 129 | request_started, 130 | ) 131 | 132 | self.app = app 133 | 134 | self.app.logger.info("Initializing Honeybadger") 135 | 136 | self.report_exceptions = report_exceptions 137 | self.reset_context_after_request = reset_context_after_request 138 | self._initialize_honeybadger(app.config) 139 | 140 | # Add hooks 141 | if self.report_exceptions: 142 | self._register_signal_handler( 143 | "auto-reporting exceptions", 144 | got_request_exception, 145 | self._handle_exception, 146 | ) 147 | 148 | if honeybadger.config.insights_enabled: 149 | self._install_sqlalchemy_instrumentation() 150 | self._register_signal_handler( 151 | "insights on request end", 152 | request_finished, 153 | self._handle_request_finished, 154 | ) 155 | 156 | self._register_signal_handler( 157 | "insights on request start", 158 | request_started, 159 | self._handle_request_started, 160 | ) 161 | 162 | if self.reset_context_after_request: 163 | self._register_signal_handler( 164 | "auto clear context on request end", 165 | request_tearing_down, 166 | self._reset_context, 167 | ) 168 | 169 | logger.info("Honeybadger helper installed") 170 | 171 | def _register_signal_handler(self, description, signal, handler): 172 | """ 173 | Registers a handler for the given signal. 174 | :param description: a short description of the signal to handle. 175 | :param signal: the signal to handle. 176 | :param handler: the function to use for handling the signal. 177 | """ 178 | from flask import signals 179 | 180 | # pylint: disable-next=no-member 181 | if hasattr(signals, "signals_available") and not signals.signals_available: 182 | self.app.logger.warn( 183 | "blinker needs to be installed in order to support {}".format( 184 | description 185 | ) 186 | ) 187 | self.app.logger.info("Enabling {}".format(description)) 188 | # Weak references won't work if handlers are methods rather than functions. 189 | signal.connect(handler, sender=self.app, weak=False) 190 | 191 | def _initialize_honeybadger(self, config): 192 | """ 193 | Initializes honeybadger using the given config object. 194 | :param dict config: a dict or dict-like object that contains honeybadger configuration properties. 195 | """ 196 | if config.get("DEBUG", False): 197 | honeybadger.configure(environment="development") 198 | 199 | honeybadger_config = extract_honeybadger_config(config) 200 | honeybadger.configure(**honeybadger_config) 201 | honeybadger.config.set_12factor_config() # environment should override Flask settings 202 | 203 | def _handle_request_started(self, sender, *args, **kwargs): 204 | from flask import request 205 | 206 | request_id = sanitize_request_id(request.headers.get("X-Request-ID")) 207 | if not request_id: 208 | request_id = str(uuid.uuid4()) 209 | 210 | honeybadger.set_event_context(request_id=request_id) 211 | 212 | _request_info.set( 213 | { 214 | "start_time": time.time(), 215 | "request": request, 216 | } 217 | ) 218 | 219 | def _handle_request_finished(self, sender, *args, **kwargs): 220 | if honeybadger.config.insights_config.flask.disabled: 221 | return 222 | 223 | info = _request_info.get({}) 224 | request = info.get("request") 225 | start = info.get("start_time") 226 | response = kwargs.get("response") 227 | 228 | payload = { 229 | "path": request.path, 230 | "method": request.method, 231 | "status": response.status_code, 232 | "view": request.endpoint, 233 | "blueprint": request.blueprint, 234 | "duration": get_duration(start), 235 | } 236 | 237 | if honeybadger.config.insights_config.flask.include_params: 238 | params = {} 239 | 240 | # Add query params (from URL) 241 | for key in request.args: 242 | values = request.args.getlist(key) 243 | params[key] = values[0] if len(values) == 1 else values 244 | 245 | # Add form params (from POST body) 246 | for key in request.form: 247 | values = request.form.getlist(key) 248 | # Combine with existing values if key present 249 | if key in params: 250 | existing = ( 251 | params[key] if isinstance(params[key], list) else [params[key]] 252 | ) 253 | params[key] = existing + values 254 | else: 255 | params[key] = values[0] if len(values) == 1 else values 256 | 257 | payload["params"] = filter_dict( 258 | params, honeybadger.config.params_filters, remove_keys=True 259 | ) 260 | 261 | honeybadger.event("flask.request", payload) 262 | 263 | _request_info.set({}) 264 | 265 | def _reset_context(self, *args, **kwargs): 266 | """ 267 | Resets context when request is done. 268 | """ 269 | honeybadger.reset_context() 270 | 271 | def _handle_exception(self, sender, exception=None): 272 | """ 273 | Actual code handling the exception and sending it to honeybadger if it's enabled. 274 | :param T sender: the object sending the exception event. 275 | :param Exception exception: the exception to handle. 276 | """ 277 | honeybadger.notify(exception) 278 | if self.reset_context_after_request: 279 | self._reset_context() 280 | 281 | def _install_sqlalchemy_instrumentation(self): 282 | """ 283 | Attach SQLAlchemy Engine events: before/after_cursor_execute => honeybadger.event 284 | """ 285 | try: 286 | import sqlalchemy # type: ignore 287 | except ImportError: 288 | return 289 | # immediate patch 290 | from sqlalchemy import event # type: ignore 291 | from sqlalchemy.engine import Engine # type: ignore 292 | 293 | @event.listens_for(Engine, "before_cursor_execute", propagate=True) 294 | def _before(conn, cursor, stmt, params, ctx, executemany): 295 | ctx._hb_start = time.time() 296 | 297 | @event.listens_for(Engine, "after_cursor_execute", propagate=True) 298 | def _after(conn, cursor, stmt, params, ctx, executemany): 299 | DBHoneybadger.execute(stmt, ctx._hb_start, params) 300 | -------------------------------------------------------------------------------- /honeybadger/tests/contrib/test_django.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | import json 3 | import importlib 4 | import uuid 5 | from mock import patch 6 | from mock import Mock 7 | import sys 8 | 9 | from django.urls import re_path 10 | from django.conf import settings 11 | from django.test import RequestFactory 12 | from django.test import SimpleTestCase 13 | from django.test import override_settings 14 | from django.test import modify_settings 15 | from django.test import Client 16 | 17 | from honeybadger import honeybadger 18 | from honeybadger.config import Configuration 19 | from honeybadger.contrib import DjangoHoneybadgerMiddleware 20 | from honeybadger.contrib.django import DjangoPlugin 21 | from honeybadger.contrib.django import clear_request 22 | from honeybadger.contrib.django import set_request 23 | from honeybadger.contrib.django import current_request 24 | 25 | from .django_test_app.views import plain_view 26 | from .django_test_app.views import always_fails 27 | from ..utils import mock_urlopen 28 | 29 | try: 30 | settings.configure() 31 | except: 32 | pass 33 | 34 | 35 | def versions_match(): 36 | import django 37 | 38 | VERSION_MATRIX = { 39 | "1.11": sys.version_info >= (3, 5), 40 | "2.2": sys.version_info >= (3, 5), 41 | "3.0": sys.version_info >= (3, 6), 42 | "3.1": sys.version_info >= (3, 6), 43 | "3.2": sys.version_info >= (3, 6), 44 | "4.2": sys.version_info >= (3, 8), 45 | } 46 | 47 | for django_version, supported in VERSION_MATRIX.items(): 48 | if ( 49 | importlib.metadata.version("django").startswith(django_version) 50 | and supported 51 | ): 52 | return True 53 | return False 54 | 55 | 56 | class DjangoPluginTestCase(unittest.TestCase): 57 | def setUp(self): 58 | self.plugin = DjangoPlugin() 59 | self.rf = RequestFactory() 60 | self.config = Configuration() 61 | self.url = re_path(r"test", plain_view, name="test_view") 62 | self.default_payload = {"request": {}} 63 | 64 | def tearDown(self): 65 | clear_request() 66 | 67 | def test_supports_django_request(self): 68 | request = self.rf.get("test") 69 | set_request(request) 70 | 71 | self.assertTrue(self.plugin.supports(self.config, {})) 72 | 73 | def test_generate_payload_get(self): 74 | request = self.rf.get("test", {"a": 1}) 75 | request.resolver_match = self.url.resolve("test") 76 | set_request(request) 77 | 78 | payload = self.plugin.generate_payload( 79 | self.default_payload, self.config, {"foo": "bar"} 80 | ) 81 | self.assertEqual(payload["request"]["url"], "http://testserver/test?a=1") 82 | self.assertEqual(payload["request"]["action"], "plain_view") 83 | self.assertDictEqual(payload["request"]["params"], {"a": ["1"]}) 84 | self.assertDictEqual(payload["request"]["session"], {}) 85 | self.assertDictEqual(payload["request"]["context"], {"foo": "bar"}) 86 | 87 | def test_generate_payload_post(self): 88 | request = self.rf.post("test", data={"a": 1, "b": 2, "password": "notsafe"}) 89 | request.resolver_match = self.url.resolve("test") 90 | set_request(request) 91 | 92 | payload = self.plugin.generate_payload( 93 | self.default_payload, self.config, {"foo": "bar"} 94 | ) 95 | self.assertEqual(payload["request"]["url"], "http://testserver/test") 96 | self.assertEqual(payload["request"]["action"], "plain_view") 97 | self.assertDictEqual( 98 | payload["request"]["params"], 99 | {"a": ["1"], "b": ["2"], "password": "[FILTERED]"}, 100 | ) 101 | self.assertDictEqual(payload["request"]["session"], {}) 102 | self.assertDictEqual(payload["request"]["context"], {"foo": "bar"}) 103 | 104 | def test_generate_payload_with_session(self): 105 | request = self.rf.get("test") 106 | request.resolver_match = self.url.resolve("test") 107 | request.session = {"lang": "en"} 108 | set_request(request) 109 | 110 | payload = self.plugin.generate_payload( 111 | self.default_payload, self.config, {"foo": "bar"} 112 | ) 113 | self.assertEqual(payload["request"]["url"], "http://testserver/test") 114 | self.assertEqual(payload["request"]["action"], "plain_view") 115 | self.assertDictEqual(payload["request"]["session"], {"lang": "en"}) 116 | self.assertDictEqual(payload["request"]["context"], {"foo": "bar"}) 117 | 118 | 119 | # TODO: add an integration test case that tests the actual integration with Django 120 | 121 | 122 | class DjangoMiddlewareTestCase(unittest.TestCase): 123 | def setUp(self): 124 | self.rf = RequestFactory() 125 | self.url = re_path(r"test", plain_view, name="test_view") 126 | 127 | def tearDown(self): 128 | clear_request() 129 | 130 | def get_response(self, request): 131 | return Mock() 132 | 133 | @patch("honeybadger.contrib.django.honeybadger") 134 | def test_process_exception(self, mock_hb): 135 | request = self.rf.get("test") 136 | request.resolver_match = self.url.resolve("test") 137 | exc = ValueError("test exception") 138 | 139 | middleware = DjangoHoneybadgerMiddleware(self.get_response) 140 | middleware.process_exception(request, exc) 141 | 142 | mock_hb.notify.assert_called_with(exc) 143 | self.assertIsNone( 144 | current_request(), 145 | msg="Current request should be cleared after exception handling", 146 | ) 147 | 148 | def test___call__(self): 149 | request = self.rf.get("test") 150 | request.resolver_match = self.url.resolve("test") 151 | 152 | middleware = DjangoHoneybadgerMiddleware(self.get_response) 153 | response = middleware(request) 154 | 155 | self.assertDictEqual( 156 | {}, 157 | honeybadger._get_context(), 158 | msg="Context should be cleared after response handling", 159 | ) 160 | self.assertIsNone( 161 | current_request(), 162 | msg="Current request should be cleared after response handling", 163 | ) 164 | 165 | 166 | @override_settings( 167 | ROOT_URLCONF="honeybadger.tests.contrib.django_test_app.urls", 168 | MIDDLEWARE=["honeybadger.contrib.django.DjangoHoneybadgerMiddleware"], 169 | ) 170 | class DjangoMiddlewareIntegrationTestCase(SimpleTestCase): 171 | def setUp(self): 172 | self.client = Client() 173 | 174 | @unittest.skipUnless( 175 | versions_match(), 176 | "Current Python version unsupported by current version of Django", 177 | ) 178 | def test_context_cleared_after_response(self): 179 | self.assertIsNone( 180 | current_request(), msg="Current request should be empty prior to request" 181 | ) 182 | response = self.client.get("/plain_view") 183 | self.assertIsNone( 184 | current_request(), 185 | msg="Current request should be cleared after request processed", 186 | ) 187 | 188 | @unittest.skipUnless( 189 | versions_match(), 190 | "Current Python version unsupported by current version of Django", 191 | ) 192 | @override_settings( 193 | HONEYBADGER={ 194 | "API_KEY": "abc123", 195 | "FORCE_REPORT_DATA": True, # Force reporting in test environment 196 | } 197 | ) 198 | def test_exceptions_handled_by_middleware(self): 199 | def assert_payload(req): 200 | error_payload = json.loads(str(req.data, "utf-8")) 201 | 202 | self.assertEqual(req.get_header("X-api-key"), "abc123") 203 | self.assertEqual( 204 | req.get_full_url(), "{}/v1/notices/".format(honeybadger.config.endpoint) 205 | ) 206 | self.assertEqual(error_payload["error"]["class"], "ValueError") 207 | self.assertEqual(error_payload["error"]["message"], "always fails") 208 | 209 | with mock_urlopen(assert_payload) as request_mock: 210 | try: 211 | response = self.client.get("/always_fails/") 212 | except: 213 | pass 214 | self.assertTrue(request_mock.called) 215 | 216 | @unittest.skipUnless( 217 | versions_match(), 218 | "Current Python version unsupported by current version of Django", 219 | ) 220 | @override_settings( 221 | MIDDLEWARE=[ 222 | "honeybadger.contrib.django.DjangoHoneybadgerMiddleware", 223 | "honeybadger.tests.contrib.django_test_app.middleware.CustomMiddleware", 224 | ], 225 | HONEYBADGER={ 226 | "API_KEY": "abc123", 227 | "FORCE_REPORT_DATA": True, # Force reporting in test environment 228 | }, 229 | ) 230 | def test_exceptions_handled_by_middleware_with_custom_middleware(self): 231 | def assert_payload(req): 232 | error_payload = json.loads(str(req.data, "utf-8")) 233 | self.assertEqual(req.get_header("X-api-key"), "abc123") 234 | self.assertEqual( 235 | req.get_full_url(), "{}/v1/notices/".format(honeybadger.config.endpoint) 236 | ) 237 | self.assertEqual(error_payload["error"]["class"], "str") 238 | self.assertEqual( 239 | error_payload["error"]["message"], "Custom Middleware Exception" 240 | ) 241 | 242 | with mock_urlopen(assert_payload) as request_mock: 243 | try: 244 | response = self.client.get("/plain_view/") 245 | except: 246 | pass 247 | self.assertTrue(request_mock.called) 248 | 249 | 250 | class FakeCursorWrapper: 251 | def __init__(self, *a, **kw): 252 | pass 253 | 254 | def execute(self, sql, params=None): 255 | return f"original execute: {sql}" 256 | 257 | 258 | @override_settings(HONEYBADGER={"INSIGHTS_ENABLED": True, "INSIGHTS_CONFIG": {}}) 259 | class DjangoMiddlewareEventTestCase(SimpleTestCase): 260 | def setUp(self): 261 | self.rf = RequestFactory() 262 | # point at your URLconf so resolver_match works 263 | self.url = re_path(r"plain_view/?$", plain_view, name="plain_view") 264 | 265 | def tearDown(self): 266 | clear_request() 267 | 268 | @patch("honeybadger.contrib.django.honeybadger.event") 269 | def test_event_sent_on_successful_request(self, mock_event): 270 | # arrange 271 | request = self.rf.get("/plain_view/") 272 | request.resolver_match = self.url.resolve("plain_view") 273 | # force a known status code 274 | response = Mock(status_code=418) 275 | mw = DjangoHoneybadgerMiddleware(lambda req: response) 276 | 277 | # act 278 | mw(request) 279 | 280 | # assert 281 | mock_event.assert_called_once() 282 | event_name, data = mock_event.call_args[0] 283 | self.assertEqual(event_name, "django.request") 284 | self.assertEqual(data["method"], "GET") 285 | self.assertEqual(data["status"], 418) 286 | self.assertEqual(data["path"], "/plain_view/") 287 | self.assertEqual(data["view"], "plain_view") 288 | # duration should be a float > 0 289 | self.assertIsInstance(data["duration"], float) 290 | self.assertGreater(data["duration"], 0) 291 | 292 | @patch("honeybadger.contrib.django.honeybadger.event") 293 | def test_no_request_left_in_thread_locals(self, mock_event): 294 | # ensure that clear_request() always runs 295 | request = self.rf.get("/plain_view/") 296 | request.resolver_match = self.url.resolve("plain_view") 297 | mw = DjangoHoneybadgerMiddleware(lambda req: Mock()) 298 | mw(request) 299 | self.assertIsNone(current_request()) 300 | 301 | @patch("django.db.backends.utils.CursorWrapper", new=FakeCursorWrapper) 302 | @patch("honeybadger.contrib.django.honeybadger.event") 303 | def test_patch_cursor_and_execute_sends_event(self, mock_event): 304 | mw = DjangoHoneybadgerMiddleware(lambda req: None) 305 | cur = FakeCursorWrapper() 306 | res = cur.execute("SELECT something") 307 | assert res == "original execute: SELECT something" 308 | mock_event.assert_called_once() 309 | 310 | @override_settings( 311 | HONEYBADGER={ 312 | "INSIGHTS_ENABLED": True, 313 | "INSIGHTS_CONFIG": {"django": {"disabled": True}}, 314 | } 315 | ) 316 | @patch("honeybadger.contrib.django.honeybadger.event") 317 | def test_event_disabled_with_disabled_config(self, mock_event): 318 | request = self.rf.get("/plain_view/") 319 | request.resolver_match = self.url.resolve("plain_view") 320 | mw = DjangoHoneybadgerMiddleware(lambda req: Mock()) 321 | mw(request) 322 | mock_event.assert_not_called() 323 | 324 | @override_settings( 325 | HONEYBADGER={ 326 | "INSIGHTS_ENABLED": True, 327 | "INSIGHTS_CONFIG": {"django": {"include_params": True}}, 328 | } 329 | ) 330 | @patch("honeybadger.contrib.django.honeybadger.event") 331 | def test_event_includes_filtered_params(self, mock_event): 332 | request = self.rf.get("/plain_view/", {"password": "hide", "b": 2}) 333 | request.resolver_match = self.url.resolve("plain_view") 334 | mw = DjangoHoneybadgerMiddleware(lambda req: Mock()) 335 | mw(request) 336 | mock_event.assert_called_once() 337 | event_name, data = mock_event.call_args[0] 338 | self.assertEqual(data["params"], {"b": "2"}) 339 | 340 | @patch("honeybadger.contrib.django.honeybadger.set_event_context") 341 | def test_existing_request_id_header(self, mock_set_event_context): 342 | req_id = "abc-123" 343 | request = self.rf.get("/foo", headers={"x-request-id": req_id}) 344 | 345 | def get_response(req): 346 | return object() 347 | 348 | mw = DjangoHoneybadgerMiddleware(get_response) 349 | mw(request) 350 | 351 | # Assert request_id propagated 352 | mock_set_event_context.assert_called_once_with(request_id=req_id) 353 | 354 | @patch("honeybadger.contrib.django.honeybadger.set_event_context") 355 | def test_missing_request_id_header(self, mock_set_event_context): 356 | request = self.rf.get("/foo") 357 | 358 | def get_response(req): 359 | return object() 360 | 361 | mw = DjangoHoneybadgerMiddleware(get_response) 362 | mw(request) 363 | 364 | # Should be a UUID 365 | request_id = mock_set_event_context.call_args[1]["request_id"] 366 | uuid_obj = uuid.UUID(request_id) 367 | assert isinstance(uuid_obj, uuid.UUID) 368 | --------------------------------------------------------------------------------