diff --git a/requirements-http.txt b/requirements-http.txt --- a/requirements-http.txt +++ b/requirements-http.txt @@ -7,3 +7,4 @@ msgpack > 0.5 python-dateutil requests +blinker # dependency of sentry-sdk[flask] diff --git a/requirements.txt b/requirements.txt --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,4 @@ Click Deprecated PyYAML +sentry-sdk diff --git a/swh/core/api/gunicorn_config.py b/swh/core/api/gunicorn_config.py new file mode 100644 --- /dev/null +++ b/swh/core/api/gunicorn_config.py @@ -0,0 +1,43 @@ +# Copyright (C) 2019 The Software Heritage developers +# See the AUTHORS file at the top-level directory of this distribution +# License: GNU General Public License version 3, or any later version +# See top-level LICENSE file for more information + +"""Default values for gunicorn's configuration. + +Other packages may override them by importing `*` from this module +and redefining functions and variables they want. + +May be imported by gunicorn using +`--config 'python:swh.core.api.gunicorn_config'`.""" + +import os + + +def _init_sentry( + sentry_dsn, *, flask=True, integrations=None, extra_kwargs={}): + import sentry_sdk + + integrations = integrations or [] + + if flask: + from sentry_sdk.integrations.flask import FlaskIntegration + integrations.append(FlaskIntegration()) + + sentry_sdk.init( + dsn=sentry_dsn, + integrations=integrations, + debug=bool(os.environ.get('SWH_SENTRY_DEBUG')), + **extra_kwargs, + ) + + +def post_fork( + server, worker, *, default_sentry_dsn=None, flask=True, + sentry_integrations=None, extra_sentry_kwargs={}): + + sentry_dsn = os.environ.get('SWH_SENTRY_DSN', default_sentry_dsn) + if sentry_dsn: + _init_sentry( + sentry_dsn, flask=flask, integrations=sentry_integrations, + extra_kwargs=extra_sentry_kwargs) diff --git a/swh/core/api/tests/test_gunicorn.py b/swh/core/api/tests/test_gunicorn.py new file mode 100644 --- /dev/null +++ b/swh/core/api/tests/test_gunicorn.py @@ -0,0 +1,77 @@ +# Copyright (C) 2019 The Software Heritage developers +# See the AUTHORS file at the top-level directory of this distribution +# License: GNU General Public License version 3, or any later version +# See top-level LICENSE file for more information + +import os +from unittest.mock import patch + +import swh.core.api.gunicorn_config as gunicorn_config + + +def test_post_fork_default(): + with patch('sentry_sdk.init') as sentry_sdk_init: + gunicorn_config.post_fork(None, None) + + sentry_sdk_init.assert_not_called() + + +def test_post_fork_with_dsn_env(): + flask_integration = object() # unique object to check for equality + with patch('sentry_sdk.integrations.flask.FlaskIntegration', + new=lambda: flask_integration): + with patch('sentry_sdk.init') as sentry_sdk_init: + with patch.dict(os.environ, {'SWH_SENTRY_DSN': 'test_dsn'}): + gunicorn_config.post_fork(None, None) + + sentry_sdk_init.assert_called_once_with( + dsn='test_dsn', + integrations=[flask_integration], + debug=False, + ) + + +def test_post_fork_debug(): + flask_integration = object() # unique object to check for equality + with patch('sentry_sdk.integrations.flask.FlaskIntegration', + new=lambda: flask_integration): + with patch('sentry_sdk.init') as sentry_sdk_init: + with patch.dict(os.environ, {'SWH_SENTRY_DSN': 'test_dsn', + 'SWH_SENTRY_DEBUG': '1'}): + gunicorn_config.post_fork(None, None) + + sentry_sdk_init.assert_called_once_with( + dsn='test_dsn', + integrations=[flask_integration], + debug=True, + ) + + +def test_post_fork_no_flask(): + with patch('sentry_sdk.init') as sentry_sdk_init: + with patch.dict(os.environ, {'SWH_SENTRY_DSN': 'test_dsn'}): + gunicorn_config.post_fork(None, None, flask=False) + + sentry_sdk_init.assert_called_once_with( + dsn='test_dsn', + integrations=[], + debug=False, + ) + + +def test_post_fork_extras(): + flask_integration = object() # unique object to check for equality + with patch('sentry_sdk.integrations.flask.FlaskIntegration', + new=lambda: flask_integration): + with patch('sentry_sdk.init') as sentry_sdk_init: + with patch.dict(os.environ, {'SWH_SENTRY_DSN': 'test_dsn'}): + gunicorn_config.post_fork( + None, None, sentry_integrations=['foo'], + extra_sentry_kwargs={'bar': 'baz'}) + + sentry_sdk_init.assert_called_once_with( + dsn='test_dsn', + integrations=['foo', flask_integration], + debug=False, + bar='baz', + ) diff --git a/swh/core/cli/__init__.py b/swh/core/cli/__init__.py --- a/swh/core/cli/__init__.py +++ b/swh/core/cli/__init__.py @@ -9,6 +9,7 @@ import click import pkg_resources +import sentry_sdk import yaml LOG_LEVEL_NAMES = ['NOTSET', 'DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'] @@ -65,13 +66,21 @@ @click.option('--log-config', default=None, type=click.File('r'), help="Python yaml logging configuration file.") +@click.option('--sentry-dsn', default=None, + help="DSN of the Sentry instance to report to") +@click.option('--sentry-debug/--no-sentry-debug', + default=False, hidden=True, + help="Enable debugging of sentry") @click.pass_context -def swh(ctx, log_level, log_config): +def swh(ctx, log_level, log_config, sentry_dsn, sentry_debug): """Command line interface for Software Heritage. """ signal.signal(signal.SIGTERM, clean_exit_on_signal) signal.signal(signal.SIGINT, clean_exit_on_signal) + if sentry_dsn: + sentry_sdk.init(dsn=sentry_dsn, debug=sentry_debug) + if log_level is None and log_config is None: log_level = 'INFO' diff --git a/swh/core/db/tests/test_cli.py b/swh/core/db/tests/test_cli.py --- a/swh/core/db/tests/test_cli.py +++ b/swh/core/db/tests/test_cli.py @@ -13,6 +13,7 @@ -l, --log-level [NOTSET|DEBUG|INFO|WARNING|ERROR|CRITICAL] Log level (defaults to INFO). --log-config FILENAME Python yaml logging configuration file. + --sentry-dsn TEXT DSN of the Sentry instance to report to -h, --help Show this message and exit. Notes: diff --git a/swh/core/tests/test_cli.py b/swh/core/tests/test_cli.py --- a/swh/core/tests/test_cli.py +++ b/swh/core/tests/test_cli.py @@ -1,7 +1,11 @@ -# +# Copyright (C) 2019 The Software Heritage developers +# See the AUTHORS file at the top-level directory of this distribution +# License: GNU General Public License version 3, or any later version +# See top-level LICENSE file for more information import logging import textwrap +from unittest.mock import patch import click from click.testing import CliRunner @@ -16,6 +20,7 @@ -l, --log-level [NOTSET|DEBUG|INFO|WARNING|ERROR|CRITICAL] Log level (defaults to INFO). --log-config FILENAME Python yaml logging configuration file. + --sentry-dsn TEXT DSN of the Sentry instance to report to -h, --help Show this message and exit. Notes: @@ -45,7 +50,9 @@ click.echo('Hello SWH!') runner = CliRunner() - result = runner.invoke(swhmain, ['test']) + with patch('sentry_sdk.init') as sentry_sdk_init: + result = runner.invoke(swhmain, ['test']) + sentry_sdk_init.assert_not_called() assert result.exit_code == 0 assert result.output.strip() == 'Hello SWH!' @@ -90,6 +97,63 @@ assert result.output.strip() == '''Hello SWH!''' +def test_sentry(swhmain): + @swhmain.command(name='test') + @click.pass_context + def swhtest(ctx): + click.echo('Hello SWH!') + + runner = CliRunner() + with patch('sentry_sdk.init') as sentry_sdk_init: + result = runner.invoke(swhmain, ['--sentry-dsn', 'test_dsn', 'test']) + assert result.exit_code == 0 + assert result.output.strip() == '''Hello SWH!''' + sentry_sdk_init.assert_called_once_with( + dsn='test_dsn', + debug=False, + ) + + +def test_sentry_debug(swhmain): + @swhmain.command(name='test') + @click.pass_context + def swhtest(ctx): + click.echo('Hello SWH!') + + runner = CliRunner() + with patch('sentry_sdk.init') as sentry_sdk_init: + result = runner.invoke( + swhmain, ['--sentry-dsn', 'test_dsn', '--sentry-debug', 'test']) + assert result.exit_code == 0 + assert result.output.strip() == '''Hello SWH!''' + sentry_sdk_init.assert_called_once_with( + dsn='test_dsn', + debug=True, + ) + + +def test_sentry_env(swhmain): + @swhmain.command(name='test') + @click.pass_context + def swhtest(ctx): + click.echo('Hello SWH!') + + runner = CliRunner() + with patch('sentry_sdk.init') as sentry_sdk_init: + env = { + 'SWH_SENTRY_DSN': 'test_dsn', + 'SWH_SENTRY_DEBUG': '1', + } + result = runner.invoke( + swhmain, ['test'], env=env, auto_envvar_prefix='SWH') + assert result.exit_code == 0 + assert result.output.strip() == '''Hello SWH!''' + sentry_sdk_init.assert_called_once_with( + dsn='test_dsn', + debug=True, + ) + + @pytest.fixture def log_config_path(tmp_path): log_config = textwrap.dedent('''\