Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

send Honeybadger alert on nonzero script exit #78

Merged
merged 5 commits into from
Feb 1, 2025
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .coveragerc
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
[run]
cov-report = term,html
omit = tests/*
concurrency = multiprocessing
parallel = true
sigterm = true
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggested by the docs: https://pytest-cov.readthedocs.io/en/latest/subprocess-support.html

but the tests still didn't seem to detect that error_reporting_wrapper.py's __main__ code did in fact get invoked.

4 changes: 3 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,9 @@ ADD ./requirements.txt requirements.txt
RUN python3 -m pip install --upgrade pip
RUN python3 -m pip install -r requirements.txt

ADD ./error_reporting_wrapper.py error_reporting_wrapper.py

ADD ./speech_to_text.py speech_to_text.py
RUN python3 -m py_compile speech_to_text.py

ENTRYPOINT ["python3", "speech_to_text.py"]
ENTRYPOINT ["python3", "error_reporting_wrapper.py", "python3", "speech_to_text.py"]
74 changes: 74 additions & 0 deletions error_reporting_wrapper.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
#!/usr/bin/env python3

import dotenv
import logging
import os
import sys

from honeybadger import honeybadger
from subprocess import run, CalledProcessError


# This must be invoked before the logger is invoked for the first time
def configure() -> None:
dotenv.load_dotenv()

Check warning on line 14 in error_reporting_wrapper.py

View check run for this annotation

Codecov / codecov/patch

error_reporting_wrapper.py#L14

Added line #L14 was not covered by tests

# TODO: HB and logging config copied from speech_to_text.py, may eventually want
# to centralize more and start organizing codebase as a package?
honeybadger.configure(

Check warning on line 18 in error_reporting_wrapper.py

View check run for this annotation

Codecov / codecov/patch

error_reporting_wrapper.py#L18

Added line #L18 was not covered by tests
api_key=os.environ.get("HONEYBADGER_API_KEY", ""),
environment=os.environ.get("HONEYBADGER_ENV", "stage"),
)

logging.basicConfig(

Check warning on line 23 in error_reporting_wrapper.py

View check run for this annotation

Codecov / codecov/patch

error_reporting_wrapper.py#L23

Added line #L23 was not covered by tests
level=logging.INFO,
format="%(asctime)s :: %(levelname)s :: %(message)s",
datefmt="%Y-%m-%dT%H:%M:%S%z",
)


# advantages of this vs using the `honeybadger exec` subcommand
# of the honeybadger gem/command (see https://docs.honeybadger.io/lib/ruby/gem-reference/cli/):
# 1. a little more control over what the error reporting sends along
# 2. more honest reporting of error code: in my testing, a script that exited with a
# return code of '1' was reported as having a return code of '256' by honeybadger
# exec (but was correctly reported by this, when compared to running the command by itself and doing `echo $?`)
# 3. this script will bubble up the return code of the wrapped script, whereas honeybadger exec always returns 0,
# even when the wrapped script exits non-zero
# 4. one or two fewer dependencies to add to the docker image (maybe ruby, depending on the base image; definitely
# the honeybadger gem regardless, whereas we already have the python package in our python deps)
#
# disadvantages of this approach: a little more code of our own to maintain
def run_with_error_reporting(cmd_with_args: list) -> int:
returncode: int

try:
completed_process = run(cmd_with_args, check=True)
returncode = completed_process.returncode

logging.info(completed_process)
except KeyboardInterrupt:
logging.info(f"exiting {sys.argv[0]}")
sys.exit()

Check warning on line 52 in error_reporting_wrapper.py

View check run for this annotation

Codecov / codecov/patch

error_reporting_wrapper.py#L51-L52

Added lines #L51 - L52 were not covered by tests
except CalledProcessError as e:
returncode = e.returncode

error_context = {
"message": str(e),
"cmd": e.cmd,
"returncode": e.returncode,
}
logging.error(error_context)
honeybadger.notify(e, context=error_context)

return returncode


if __name__ == "__main__":
configure()

Check warning on line 68 in error_reporting_wrapper.py

View check run for this annotation

Codecov / codecov/patch

error_reporting_wrapper.py#L68

Added line #L68 was not covered by tests

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i think this complaint is spurious, and that the tests do actually cover this?

cmd_with_args = sys.argv[1:] # argv[0] is this script's name
logging.info(f"command and args: {cmd_with_args}")

Check warning on line 71 in error_reporting_wrapper.py

View check run for this annotation

Codecov / codecov/patch

error_reporting_wrapper.py#L70-L71

Added lines #L70 - L71 were not covered by tests

# bubble up the exit code from the wrapped call
sys.exit(run_with_error_reporting(cmd_with_args))

Check warning on line 74 in error_reporting_wrapper.py

View check run for this annotation

Codecov / codecov/patch

error_reporting_wrapper.py#L74

Added line #L74 was not covered by tests
59 changes: 59 additions & 0 deletions tests/test_error_reporting_wrapper.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import error_reporting_wrapper
import pytest

from subprocess import run, CalledProcessError, CompletedProcess

from unittest.mock import patch


def test_error_reporting_wrapper_exit_zero():
completed_process = run(["cat", "Dockerfile"], check=True)
assert completed_process.returncode == 0, "return code for successful command is 0"


def test_error_reporting_wrapper_exit_nonzero():
completed_process = run(["cat", "foooooo"])
assert completed_process.returncode == 1, (
"return code for unsuccessful command is bubbled up through wrapper"
)


@patch("error_reporting_wrapper.honeybadger")
def test_run_with_error_reporting_on_error_honeybadger(mock_honeybadger):
returncode = error_reporting_wrapper.run_with_error_reporting(["cat", "foooooo"])

mock_honeybadger.notify.assert_called_once()
args, kwargs = mock_honeybadger.notify.call_args
context = kwargs["context"]
assert isinstance(args[0], CalledProcessError)
assert "returned non-zero exit status" in context["message"]
assert context["cmd"] == ["cat", "foooooo"]
assert context["returncode"] == 1
assert returncode == 1


# ignore utcnow warning from within honeybadger
@pytest.mark.filterwarnings("ignore:datetime.datetime.utcnow")
@patch("error_reporting_wrapper.logging")
def test_run_with_error_reporting_on_error_logging(mock_logging):
returncode = error_reporting_wrapper.run_with_error_reporting(["cat", "foooooo"])

mock_logging.error.assert_called_once()
args, kwargs = mock_logging.error.call_args
assert "returned non-zero exit status" in args[0]["message"]
assert args[0]["cmd"] == ["cat", "foooooo"]
assert args[0]["returncode"] == 1
assert returncode == 1


@patch("error_reporting_wrapper.logging")
@patch("error_reporting_wrapper.honeybadger")
def test_run_with_error_reporting_on_success(mock_honeybadger, mock_logging):
returncode = error_reporting_wrapper.run_with_error_reporting(["cat", "Dockerfile"])

mock_honeybadger.notify.assert_not_called()
mock_logging.error.assert_not_called()
mock_logging.info.assert_called_once()
args = mock_logging.info.call_args.args
assert isinstance(args[0], CompletedProcess)
assert returncode == 0