diff --git a/.changesets/rename-heartbeats-to-cron-check-ins.md b/.changesets/rename-heartbeats-to-cron-check-ins.md new file mode 100644 index 00000000..b554b1a7 --- /dev/null +++ b/.changesets/rename-heartbeats-to-cron-check-ins.md @@ -0,0 +1,26 @@ +--- +bump: patch +type: change +--- + +Rename heartbeats to cron check-ins. Calls to `appsignal.heartbeat` and `appsignal.Heartbeat` should be replaced with calls to `appsignal.check_in.cron` and `appsignal.check_in.Cron`, for example: + +```python +# Before +from appsignal import heartbeat + +def do_something(): + pass + +heartbeat("do_something", do_something) + +# After +from appsignal.check_in import cron + +def do_something(): + pass + +cron("do_something", do_something) +``` + +Calls to `appsignal.heartbeat` and to the `appsignal.Heartbeat` constructor will emit a deprecation warning. diff --git a/conftest.py b/conftest.py index 2874848c..2c6eb79f 100644 --- a/conftest.py +++ b/conftest.py @@ -17,6 +17,7 @@ from appsignal import probes from appsignal.agent import agent from appsignal.client import _reset_client +from appsignal.heartbeat import _heartbeat_class_warning, _heartbeat_helper_warning from appsignal.internal_logger import _reset_logger from appsignal.opentelemetry import METRICS_PREFERRED_TEMPORALITY @@ -110,3 +111,11 @@ def stop_agent(): working_dir = os.path.join(tmp_path, "appsignal") if os.path.isdir(working_dir): os.system(f"rm -rf {working_dir}") + + +@pytest.fixture(scope="function") +def reset_heartbeat_warnings(): + _heartbeat_class_warning.reset() + _heartbeat_helper_warning.reset() + + yield diff --git a/src/appsignal/__init__.py b/src/appsignal/__init__.py index 37c206d0..3bfe3aea 100644 --- a/src/appsignal/__init__.py +++ b/src/appsignal/__init__.py @@ -3,6 +3,7 @@ import os from runpy import run_path +from . import check_in from .client import Client as Appsignal from .heartbeat import Heartbeat, heartbeat from .metrics import add_distribution_value, increment_counter, set_gauge @@ -45,6 +46,7 @@ "add_distribution_value", "Heartbeat", "heartbeat", + "check_in", "start", ] diff --git a/src/appsignal/check_in.py b/src/appsignal/check_in.py new file mode 100644 index 00000000..b85d09c1 --- /dev/null +++ b/src/appsignal/check_in.py @@ -0,0 +1,87 @@ +from __future__ import annotations + +from binascii import hexlify +from os import urandom +from time import time +from typing import Any, Callable, Literal, TypedDict, TypeVar, Union + +from . import internal_logger as logger +from .client import Client +from .config import Config +from .transmitter import transmit + + +T = TypeVar("T") + +EventKind = Union[Literal["start"], Literal["finish"]] + + +class Event(TypedDict): + name: str + id: str + kind: EventKind + timestamp: int + + +class Cron: + name: str + id: str + + def __init__(self, name: str) -> None: + self.name = name + self.id = hexlify(urandom(8)).decode("utf-8") + + def _event(self, kind: EventKind) -> Event: + return Event(name=self.name, id=self.id, kind=kind, timestamp=int(time())) + + def _transmit(self, event: Event) -> None: + config = Client.config() or Config() + + if not config.is_active(): + logger.debug("AppSignal not active, not transmitting cron check-in event") + return + + url = f"{config.option('logging_endpoint')}/checkins/cron/json" + try: + response = transmit(url, json=event) + if 200 <= response.status_code <= 299: + logger.debug( + f"Transmitted cron check-in `{event['name']}` ({event['id']}) " + f"{event['kind']} event" + ) + else: + logger.error( + f"Failed to transmit cron check-in {event['kind']} event: " + f"status code was {response.status_code}" + ) + except Exception as e: + logger.error(f"Failed to transmit cron check-in {event['kind']} event: {e}") + + def start(self) -> None: + self._transmit(self._event("start")) + + def finish(self) -> None: + self._transmit(self._event("finish")) + + def __enter__(self) -> None: + self.start() + + def __exit__( + self, exc_type: Any = None, exc_value: Any = None, traceback: Any = None + ) -> Literal[False]: + if exc_type is None: + self.finish() + + return False + + +def cron(name: str, fn: Callable[[], T] | None = None) -> None | T: + cron = Cron(name) + output = None + + if fn is not None: + cron.start() + output = fn() + + cron.finish() + return output diff --git a/src/appsignal/heartbeat.py b/src/appsignal/heartbeat.py index 2c8c4d3b..c22d573a 100644 --- a/src/appsignal/heartbeat.py +++ b/src/appsignal/heartbeat.py @@ -1,87 +1,65 @@ from __future__ import annotations -from binascii import hexlify -from os import urandom -from time import time -from typing import Any, Callable, Literal, TypedDict, TypeVar, Union +import warnings +from typing import Any, Callable, TypeVar from . import internal_logger as logger -from .client import Client -from .config import Config -from .transmitter import transmit +from .check_in import Cron, cron T = TypeVar("T") -EventKind = Union[Literal["start"], Literal["finish"]] +class _Once: + def __init__(self, func: Callable[..., None], *args: Any, **kwargs: Any) -> None: + self.called = False + self.func = func + self.args = args + self.kwargs = kwargs -class Event(TypedDict): - name: str - id: str - kind: EventKind - timestamp: int + def __call__(self) -> None: + if not self.called: + self.called = True + self.func(*self.args, **self.kwargs) + def reset(self) -> None: + self.called = False -class Heartbeat: - name: str - id: str - def __init__(self, name: str) -> None: - self.name = name - self.id = hexlify(urandom(8)).decode("utf-8") +def _warn_logger_and_stdout(msg: str) -> None: + logger.warning(msg) + print(f"appsignal WARNING: {msg}") - def _event(self, kind: EventKind) -> Event: - return Event(name=self.name, id=self.id, kind=kind, timestamp=int(time())) - def _transmit(self, event: Event) -> None: - config = Client.config() or Config() +_heartbeat_helper_warning = _Once( + _warn_logger_and_stdout, + "The helper `heartbeat` has been deprecated. " + "Please update uses of the helper `heartbeat(...)` to `cron(...)`, " + "importing it as `from appsignal.check_in import cron`, " + "in order to remove this message.", +) - if not config.is_active(): - logger.debug("AppSignal not active, not transmitting heartbeat event") - return +_heartbeat_class_warning = _Once( + _warn_logger_and_stdout, + "The class `Heartbeat` has been deprecated. " + "Please update uses of the class `Heartbeat(...)` to `Cron(...)` " + "importing it as `from appsignal.check_in import Cron`, " + "in order to remove this message.", +) - url = f"{config.option('logging_endpoint')}/heartbeats/json" - try: - response = transmit(url, json=event) - if 200 <= response.status_code <= 299: - logger.debug( - f"Transmitted heartbeat `{event['name']}` ({event['id']}) " - f"{event['kind']} event" - ) - else: - logger.error( - "Failed to transmit heartbeat event: " - f"status code was {response.status_code}" - ) - except Exception as e: - logger.error(f"Failed to transmit heartbeat event: {e}") - def start(self) -> None: - self._transmit(self._event("start")) - - def finish(self) -> None: - self._transmit(self._event("finish")) - - def __enter__(self) -> None: - self.start() - - def __exit__( - self, exc_type: Any = None, exc_value: Any = None, traceback: Any = None - ) -> Literal[False]: - if exc_type is None: - self.finish() - - return False +def heartbeat(name: str, fn: Callable[[], T] | None = None) -> None | T: + _heartbeat_helper_warning() + return cron(name, fn) -def heartbeat(name: str, fn: Callable[[], T] | None = None) -> None | T: - heartbeat = Heartbeat(name) - output = None +class _MetaHeartbeat(type): + def __instancecheck__(cls, other: Any) -> bool: + _heartbeat_class_warning() + return isinstance(other, Cron) - if fn is not None: - heartbeat.start() - output = fn() - heartbeat.finish() - return output +class Heartbeat(metaclass=_MetaHeartbeat): + def __new__(cls, name: str) -> Cron: # type: ignore[misc] + _heartbeat_class_warning() + return Cron(name) diff --git a/tests/test_check_in.py b/tests/test_check_in.py new file mode 100644 index 00000000..e4a9c527 --- /dev/null +++ b/tests/test_check_in.py @@ -0,0 +1,310 @@ +from time import sleep + +from pytest import raises + +from appsignal import Heartbeat, heartbeat +from appsignal.check_in import Cron, cron +from appsignal.client import Client + + +def init_client(active=True): + Client( + push_api_key="some-push-api-key", + name="some-app", + environment="staging", + hostname="beepboop.local", + active=active, + ) + + +def mock_requests(mocker, status_code=200, raise_exception=False): + requests_mock = mocker.patch("requests.post") + requests_mock.return_value.status_code = status_code + if raise_exception: + + def side_effect(*args, **kwargs): + raise Exception("Whoops!") + + requests_mock.side_effect = side_effect + + return requests_mock + + +def mock_internal_logger(mocker, level): + return mocker.patch(f"appsignal.internal_logger.{level}") + + +def mock_print(mocker): + return mocker.patch("builtins.print") + + +def test_cron_start_and_finish_when_appsignal_is_not_active_sends_nothing(mocker): + requests_mock = mock_requests(mocker) + init_client(active=False) + + cron = Cron("some-cron-checkin") + cron.start() + cron.finish() + + assert not requests_mock.called + + +def test_cron_start_sends_cron_checkin_start_event(mocker): + requests_mock = mock_requests(mocker) + init_client() + + cron = Cron("some-cron-checkin") + cron.start() + + assert requests_mock.called + assert ( + "https://appsignal-endpoint.net/checkins/cron/json?" + in requests_mock.call_args[0][0] + ) + # The ordering of query parameters is not guaranteed. + assert "api_key=some-push-api-key" in requests_mock.call_args[0][0] + assert "environment=staging" in requests_mock.call_args[0][0] + assert "hostname=beepboop.local" in requests_mock.call_args[0][0] + assert "name=some-app" in requests_mock.call_args[0][0] + + assert requests_mock.call_args[1]["json"]["name"] == "some-cron-checkin" + assert requests_mock.call_args[1]["json"]["kind"] == "start" + assert isinstance(requests_mock.call_args[1]["json"]["timestamp"], int) + assert isinstance(requests_mock.call_args[1]["json"]["id"], str) + + +def test_cron_finish_sends_cron_checkin_finish_event(mocker): + requests_mock = mock_requests(mocker) + init_client() + + cron = Cron("some-cron-checkin") + cron.finish() + + assert requests_mock.called + assert ( + "https://appsignal-endpoint.net/checkins/cron/json?" + in requests_mock.call_args[0][0] + ) + # The ordering of query parameters is not guaranteed. + assert "api_key=some-push-api-key" in requests_mock.call_args[0][0] + assert "environment=staging" in requests_mock.call_args[0][0] + assert "hostname=beepboop.local" in requests_mock.call_args[0][0] + assert "name=some-app" in requests_mock.call_args[0][0] + + assert requests_mock.call_args[1]["json"]["name"] == "some-cron-checkin" + assert requests_mock.call_args[1]["json"]["kind"] == "finish" + assert isinstance(requests_mock.call_args[1]["json"]["timestamp"], int) + assert isinstance(requests_mock.call_args[1]["json"]["id"], str) + + +def test_cron_sends_cron_checkin_finish_event(mocker): + requests_mock = mock_requests(mocker) + init_client() + + cron("some-cron-checkin") + + assert requests_mock.called + assert len(requests_mock.call_args_list) == 1 + + assert requests_mock.call_args[1]["json"]["name"] == "some-cron-checkin" + assert requests_mock.call_args[1]["json"]["kind"] == "finish" + + +def test_cron_with_function_sends_cron_checkin_start_and_finish_event(mocker): + requests_mock = mock_requests(mocker) + init_client() + + def some_function(): + sleep(1.1) + return "output" + + assert cron("some-cron-checkin", some_function) == "output" + + assert requests_mock.called + assert len(requests_mock.call_args_list) == 2 + + assert requests_mock.call_args_list[0][1]["json"]["name"] == "some-cron-checkin" + assert requests_mock.call_args_list[0][1]["json"]["kind"] == "start" + assert requests_mock.call_args_list[1][1]["json"]["name"] == "some-cron-checkin" + assert requests_mock.call_args_list[1][1]["json"]["kind"] == "finish" + assert ( + requests_mock.call_args_list[0][1]["json"]["timestamp"] + < requests_mock.call_args_list[1][1]["json"]["timestamp"] + ) + assert ( + requests_mock.call_args_list[0][1]["json"]["id"] + == requests_mock.call_args_list[1][1]["json"]["id"] + ) + + +def test_cron_with_function_does_not_send_cron_checkin_finish_event_on_exception( + mocker, +): + requests_mock = mock_requests(mocker) + init_client() + + def some_function(): + raise Exception("Whoops!") + + with raises(Exception, match="Whoops!"): + cron("some-cron-checkin", some_function) + + assert requests_mock.called + assert len(requests_mock.call_args_list) == 1 + + assert requests_mock.call_args_list[0][1]["json"]["name"] == "some-cron-checkin" + assert requests_mock.call_args_list[0][1]["json"]["kind"] == "start" + + +def test_cron_context_manager_sends_cron_checkin_start_and_finish_event(mocker): + requests_mock = mock_requests(mocker) + init_client() + + with Cron("some-cron-checkin"): + sleep(1.1) + + assert requests_mock.called + assert len(requests_mock.call_args_list) == 2 + + assert requests_mock.call_args_list[0][1]["json"]["name"] == "some-cron-checkin" + assert requests_mock.call_args_list[0][1]["json"]["kind"] == "start" + assert requests_mock.call_args_list[1][1]["json"]["name"] == "some-cron-checkin" + assert requests_mock.call_args_list[1][1]["json"]["kind"] == "finish" + assert ( + requests_mock.call_args_list[0][1]["json"]["timestamp"] + < requests_mock.call_args_list[1][1]["json"]["timestamp"] + ) + assert ( + requests_mock.call_args_list[0][1]["json"]["id"] + == requests_mock.call_args_list[1][1]["json"]["id"] + ) + + +def test_cron_context_manager_does_not_send_cron_checkin_finish_event_on_exception( + mocker, +): + requests_mock = mock_requests(mocker) + init_client() + + with raises(Exception, match="Whoops!"): + with Cron("some-cron-checkin"): + raise Exception("Whoops!") + + assert requests_mock.called + assert len(requests_mock.call_args_list) == 1 + + assert requests_mock.call_args_list[0][1]["json"]["name"] == "some-cron-checkin" + assert requests_mock.call_args_list[0][1]["json"]["kind"] == "start" + + +def test_cron_logs_failure_to_send_event_when_status_code(mocker): + mock_requests(mocker, status_code=500) + init_client() + + internal_logger_mock = mock_internal_logger(mocker, "error") + + cron = Cron("some-cron-checkin") + cron.start() + + assert internal_logger_mock.called + assert len(internal_logger_mock.call_args_list) == 1 + assert ( + "Failed to transmit cron check-in start event: status code was 500" + in internal_logger_mock.call_args[0][0] + ) + + +def test_cron_logs_failure_to_send_event_when_exception(mocker): + mock_requests(mocker, raise_exception=True) + init_client() + + internal_logger_mock = mock_internal_logger(mocker, "error") + + cron = Cron("some-cron-checkin") + cron.start() + + assert internal_logger_mock.called + assert len(internal_logger_mock.call_args_list) == 1 + assert ( + "Failed to transmit cron check-in start event: Whoops!" + in internal_logger_mock.call_args[0][0] + ) + + +def test_heartbeat_helper_behaves_like_cron_helper(mocker): + requests_mock = mock_requests(mocker) + init_client() + + def some_function(): + return "output" + + assert heartbeat("some-heartbeat", some_function) == "output" + + assert requests_mock.called + assert len(requests_mock.call_args_list) == 2 + + assert requests_mock.call_args_list[0][1]["json"]["name"] == "some-heartbeat" + assert requests_mock.call_args_list[0][1]["json"]["kind"] == "start" + assert requests_mock.call_args_list[1][1]["json"]["name"] == "some-heartbeat" + assert requests_mock.call_args_list[1][1]["json"]["kind"] == "finish" + + +def test_heartbeat_helper_emits_deprecation_warning(mocker, reset_heartbeat_warnings): + internal_logger_mock = mock_internal_logger(mocker, "warning") + print_mock = mock_print(mocker) + + heartbeat("some-heartbeat") + + for mock in [internal_logger_mock, print_mock]: + assert mock.called + assert len(mock.call_args_list) == 1 + assert "The helper `heartbeat` has been deprecated" in mock.call_args[0][0] + + +def test_heartbeat_helper_only_emits_deprecation_warning_once( + mocker, reset_heartbeat_warnings +): + internal_logger_mock = mock_internal_logger(mocker, "warning") + print_mock = mock_print(mocker) + + heartbeat("some-heartbeat") + heartbeat("some-heartbeat") + + for mock in [internal_logger_mock, print_mock]: + assert mock.call_count == 1 + + +def test_heartbeat_class_returns_cron_instance(): + cron_instance = Heartbeat("some-heartbeat") + assert isinstance(cron_instance, Cron) + + +def test_cron_instance_as_instance_of_heartbeat(): + for instance_class in [Cron, Heartbeat]: + for checked_class in [Cron, Heartbeat]: + assert isinstance(instance_class("some-instance"), checked_class) + + +def test_heartbeat_class_emits_deprecation_warning(mocker, reset_heartbeat_warnings): + internal_logger_mock = mock_internal_logger(mocker, "warning") + print_mock = mock_print(mocker) + + Heartbeat("some-heartbeat") + + for mock in [internal_logger_mock, print_mock]: + assert mock.called + assert len(mock.call_args_list) == 1 + assert "The class `Heartbeat` has been deprecated" in mock.call_args[0][0] + + +def test_heartbeat_class_only_emits_deprecation_warning_once( + mocker, reset_heartbeat_warnings +): + internal_logger_mock = mock_internal_logger(mocker, "warning") + print_mock = mock_print(mocker) + + Heartbeat("some-heartbeat") + Heartbeat("some-heartbeat") + + for mock in [internal_logger_mock, print_mock]: + assert mock.call_count == 1 diff --git a/tests/test_heartbeat.py b/tests/test_heartbeat.py deleted file mode 100644 index d747ea56..00000000 --- a/tests/test_heartbeat.py +++ /dev/null @@ -1,175 +0,0 @@ -from time import sleep - -from pytest import raises - -from appsignal.client import Client -from appsignal.heartbeat import Heartbeat, heartbeat - - -def init_client(active=True): - Client( - push_api_key="some-push-api-key", - name="some-app", - environment="staging", - hostname="beepboop.local", - active=active, - ) - - -def test_start_finish_when_appsignal_is_not_active_sends_nothing(mocker): - mock_request = mocker.patch("requests.post") - init_client(active=False) - - heartbeat = Heartbeat("some-heartbeat") - heartbeat.start() - heartbeat.finish() - - assert not mock_request.called - - -def test_start_sends_heartbeat_start_event(mocker): - mock_request = mocker.patch("requests.post") - init_client() - - heartbeat = Heartbeat("some-heartbeat") - heartbeat.start() - - assert mock_request.called - assert ( - "https://appsignal-endpoint.net/heartbeats/json?" - in mock_request.call_args[0][0] - ) - # The ordering of query parameters is not guaranteed. - assert "api_key=some-push-api-key" in mock_request.call_args[0][0] - assert "environment=staging" in mock_request.call_args[0][0] - assert "hostname=beepboop.local" in mock_request.call_args[0][0] - assert "name=some-app" in mock_request.call_args[0][0] - - assert mock_request.call_args[1]["json"]["name"] == "some-heartbeat" - assert mock_request.call_args[1]["json"]["kind"] == "start" - assert isinstance(mock_request.call_args[1]["json"]["timestamp"], int) - assert isinstance(mock_request.call_args[1]["json"]["id"], str) - - -def test_finish_sends_heartbeat_finish_event(mocker): - mock_request = mocker.patch("requests.post") - init_client() - - heartbeat = Heartbeat("some-heartbeat") - heartbeat.finish() - - assert mock_request.called - assert ( - "https://appsignal-endpoint.net/heartbeats/json?" - in mock_request.call_args[0][0] - ) - # The ordering of query parameters is not guaranteed. - assert "api_key=some-push-api-key" in mock_request.call_args[0][0] - assert "environment=staging" in mock_request.call_args[0][0] - assert "hostname=beepboop.local" in mock_request.call_args[0][0] - assert "name=some-app" in mock_request.call_args[0][0] - - assert mock_request.call_args[1]["json"]["name"] == "some-heartbeat" - assert mock_request.call_args[1]["json"]["kind"] == "finish" - assert isinstance(mock_request.call_args[1]["json"]["timestamp"], int) - assert isinstance(mock_request.call_args[1]["json"]["id"], str) - - -def test_heartbeat_sends_heartbeat_finish_event(mocker): - mock_request = mocker.patch("requests.post") - init_client() - - heartbeat("some-heartbeat") - - assert mock_request.called - assert len(mock_request.call_args_list) == 1 - - assert mock_request.call_args[1]["json"]["name"] == "some-heartbeat" - assert mock_request.call_args[1]["json"]["kind"] == "finish" - - -def test_heartbeat_with_function_sends_heartbeat_start_and_finish_event(mocker): - mock_request = mocker.patch("requests.post") - init_client() - - def some_function(): - sleep(1.1) - return "output" - - assert heartbeat("some-heartbeat", some_function) == "output" - - assert mock_request.called - assert len(mock_request.call_args_list) == 2 - - assert mock_request.call_args_list[0][1]["json"]["name"] == "some-heartbeat" - assert mock_request.call_args_list[0][1]["json"]["kind"] == "start" - assert mock_request.call_args_list[1][1]["json"]["name"] == "some-heartbeat" - assert mock_request.call_args_list[1][1]["json"]["kind"] == "finish" - assert ( - mock_request.call_args_list[0][1]["json"]["timestamp"] - < mock_request.call_args_list[1][1]["json"]["timestamp"] - ) - assert ( - mock_request.call_args_list[0][1]["json"]["id"] - == mock_request.call_args_list[1][1]["json"]["id"] - ) - - -def test_heartbeat_with_function_does_not_send_heartbeat_finish_event_on_exception( - mocker, -): - mock_request = mocker.patch("requests.post") - init_client() - - def some_function(): - raise Exception("Whoops!") - - with raises(Exception, match="Whoops!"): - heartbeat("some-heartbeat", some_function) - - assert mock_request.called - assert len(mock_request.call_args_list) == 1 - - assert mock_request.call_args_list[0][1]["json"]["name"] == "some-heartbeat" - assert mock_request.call_args_list[0][1]["json"]["kind"] == "start" - - -def test_heartbeat_context_manager_sends_heartbeat_start_and_finish_event(mocker): - mock_request = mocker.patch("requests.post") - init_client() - - with Heartbeat("some-heartbeat"): - sleep(1.1) - - assert mock_request.called - assert len(mock_request.call_args_list) == 2 - - assert mock_request.call_args_list[0][1]["json"]["name"] == "some-heartbeat" - assert mock_request.call_args_list[0][1]["json"]["kind"] == "start" - assert mock_request.call_args_list[1][1]["json"]["name"] == "some-heartbeat" - assert mock_request.call_args_list[1][1]["json"]["kind"] == "finish" - assert ( - mock_request.call_args_list[0][1]["json"]["timestamp"] - < mock_request.call_args_list[1][1]["json"]["timestamp"] - ) - assert ( - mock_request.call_args_list[0][1]["json"]["id"] - == mock_request.call_args_list[1][1]["json"]["id"] - ) - - -def test_heartbeat_context_manager_does_not_send_heartbeat_finish_event_on_exception( - mocker, -): - mock_request = mocker.patch("requests.post") - init_client() - - with raises(Exception, match="Whoops!"): - with Heartbeat("some-heartbeat"): - raise Exception("Whoops!") - - assert mock_request.called - assert len(mock_request.call_args_list) == 1 - - assert mock_request.call_args_list[0][1]["json"]["name"] == "some-heartbeat" - assert mock_request.call_args_list[0][1]["json"]["kind"] == "start"