From 478c44e2f54360eaadcc8435cd75cdba87eb26f9 Mon Sep 17 00:00:00 2001 From: Noemi <45180344+unflxw@users.noreply.github.com> Date: Tue, 9 Apr 2024 12:51:02 +0200 Subject: [PATCH 1/5] Store global client and expose its config Store the last (hopefully only) initialised client in a global variable, and expose its config as a class method on the `Client` class. --- conftest.py | 4 ++++ src/appsignal/client.py | 17 +++++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/conftest.py b/conftest.py index c41a328..1d63b11 100644 --- a/conftest.py +++ b/conftest.py @@ -18,6 +18,7 @@ from appsignal import probes from appsignal.agent import agent from appsignal.opentelemetry import METRICS_PREFERRED_TEMPORALITY +from appsignal.client import _reset_client @pytest.fixture(scope="function", autouse=True) @@ -99,6 +100,9 @@ def stop_and_clear_probes_after_tests(): def reset_agent_active_state(): agent.active = False +@pytest.fixture(scope="function", autouse=True) +def reset_global_client(): + _reset_client() @pytest.fixture(scope="function", autouse=True) def stop_agent(): diff --git a/src/appsignal/client.py b/src/appsignal/client.py index d7c6b04..371b7bc 100644 --- a/src/appsignal/client.py +++ b/src/appsignal/client.py @@ -15,6 +15,13 @@ from typing_extensions import Unpack +_client: Client | None = None + +def _reset_client() -> None: + global _client + _client = None + + class Client: _logger: Logger _config: Config @@ -28,12 +35,22 @@ class Client: } def __init__(self, **options: Unpack[Options]) -> None: + global _client + self._config = Config(options) self._start_logger() + _client = self if not self._config.is_active(): self._logger.info("AppSignal not starting: no active config found") + @classmethod + def config(cls) -> Config | None: + if _client is None: + return None + + return _client._config + def start(self) -> None: if self._config.is_active(): self._logger.info("Starting AppSignal") From 762835f2ffe95c96d08f6ce197b5a9205c1845da Mon Sep 17 00:00:00 2001 From: Noemi <45180344+unflxw@users.noreply.github.com> Date: Tue, 9 Apr 2024 12:56:20 +0200 Subject: [PATCH 2/5] Implement AppSignal transmitter Implement a transmitter module similar to the ones in other integrations, which handles the sending of requests with the usual AppSignal identifying params. --- src/appsignal/cli/diagnose.py | 18 +---- src/appsignal/push_api_key_validator.py | 23 +------ src/appsignal/transmitter.py | 40 +++++++++++ tests/test_push_api_key_validator.py | 31 ++++++--- tests/test_transmitter.py | 88 +++++++++++++++++++++++++ 5 files changed, 157 insertions(+), 43 deletions(-) create mode 100644 src/appsignal/transmitter.py create mode 100644 tests/test_transmitter.py diff --git a/src/appsignal/cli/diagnose.py b/src/appsignal/cli/diagnose.py index a26e3a6..9b24f9e 100644 --- a/src/appsignal/cli/diagnose.py +++ b/src/appsignal/cli/diagnose.py @@ -5,18 +5,16 @@ import json import os import platform -import urllib from argparse import ArgumentParser from pathlib import Path from sys import stderr from typing import Any -import requests - from ..__about__ import __version__ from ..agent import Agent from ..config import Config from ..push_api_key_validator import PushApiKeyValidator +from ..transmitter import transmit from .command import AppsignalCLICommand @@ -377,19 +375,9 @@ def _report_prompt(self) -> bool | None: return None def _send_diagnose_report(self) -> None: - params = urllib.parse.urlencode( - { - "api_key": self.config.option("push_api_key"), - "name": self.config.option("name"), - "environment": self.config.option("environment"), - "hostname": self.config.option("hostname") or "", - } - ) - - endpoint = self.config.option("diagnose_endpoint") - url = f"{endpoint}?{params}" + url = self.config.option("diagnose_endpoint") - response = requests.post(url, json={"diagnose": self.report}) + response = transmit(url, json={"diagnose": self.report}, config=self.config) status = response.status_code if status == 200: diff --git a/src/appsignal/push_api_key_validator.py b/src/appsignal/push_api_key_validator.py index 367a7ab..cde2cd7 100644 --- a/src/appsignal/push_api_key_validator.py +++ b/src/appsignal/push_api_key_validator.py @@ -1,31 +1,14 @@ -import urllib - -import requests - from appsignal.config import Config +from appsignal.transmitter import transmit class PushApiKeyValidator: @staticmethod def validate(config: Config) -> str: endpoint = config.option("endpoint") - params = urllib.parse.urlencode( - { - "api_key": config.option("push_api_key"), - "name": config.option("name"), - "environment": config.option("environment"), - "hostname": config.option("hostname") or "", - } - ) - - url = f"{endpoint}/1/auth?{params}" - proxies = {} - if config.option("http_proxy"): - proxies["http"] = config.option("http_proxy") - proxies["https"] = config.option("http_proxy") + url = f"{endpoint}/1/auth" - cert = config.option("ca_file_path") - response = requests.post(url, proxies=proxies, verify=cert) + response = transmit(url, config=config) if response.status_code == 200: return "valid" diff --git a/src/appsignal/transmitter.py b/src/appsignal/transmitter.py new file mode 100644 index 0000000..4c1527e --- /dev/null +++ b/src/appsignal/transmitter.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +import urllib +from typing import TYPE_CHECKING, Any + +import requests + +from appsignal.client import Client +from appsignal.config import Config + + +if TYPE_CHECKING: + from requests import Response + + +def transmit( + url: str, json: Any | None = None, config: Config | None = None +) -> Response: + if config is None: + config = Client.config() or Config() + + params = urllib.parse.urlencode( + { + "api_key": config.option("push_api_key") or "", + "name": config.option("name") or "", + "environment": config.option("environment") or "", + "hostname": config.option("hostname") or "", + } + ) + + url = f"{url}?{params}" + + proxies = {} + if config.option("http_proxy"): + proxies["http"] = config.option("http_proxy") + proxies["https"] = config.option("http_proxy") + + cert = config.option("ca_file_path") + + return requests.post(url, json=json, proxies=proxies, verify=cert) diff --git a/tests/test_push_api_key_validator.py b/tests/test_push_api_key_validator.py index b626894..005ca93 100644 --- a/tests/test_push_api_key_validator.py +++ b/tests/test_push_api_key_validator.py @@ -4,27 +4,42 @@ from appsignal.push_api_key_validator import PushApiKeyValidator +config = Config( + Options( + push_api_key="some-push-api-key", + name="some-app", + environment="staging", + hostname="beepboop.local", + ) +) + + def test_push_api_key_validator_valid(mocker): mock_request = mocker.patch("requests.post") mock_request.return_value = MagicMock(status_code=200) - assert ( - PushApiKeyValidator.validate(Config(Options(push_api_key="valid"))) == "valid" - ) + assert PushApiKeyValidator.validate(config) == "valid" + assert mock_request.called + + assert "https://push.appsignal.com/1/auth?" 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] def test_push_api_key_validator_invalid(mocker): mock_request = mocker.patch("requests.post") mock_request.return_value = MagicMock(status_code=401) - assert ( - PushApiKeyValidator.validate(Config(Options(push_api_key="invalid"))) - == "invalid" - ) + assert PushApiKeyValidator.validate(config) == "invalid" + assert mock_request.called def test_push_api_key_validator_error(mocker): mock_request = mocker.patch("requests.post") mock_request.return_value = MagicMock(status_code=500) - assert PushApiKeyValidator.validate(Config(Options(push_api_key="500"))) == "500" + assert PushApiKeyValidator.validate(config) == "500" + assert mock_request.called diff --git a/tests/test_transmitter.py b/tests/test_transmitter.py new file mode 100644 index 0000000..9a85330 --- /dev/null +++ b/tests/test_transmitter.py @@ -0,0 +1,88 @@ +from appsignal.client import Client +from appsignal.config import Config, Options +from appsignal.transmitter import transmit + + +options = Options( + push_api_key="some-push-api-key", + name="some-app", + environment="staging", + hostname="beepboop.local", +) + +config = Config(options) + + +def test_transmitter_adds_query_parameters_from_confing(mocker): + mock_request = mocker.patch("requests.post") + + transmit("https://example.url/", config=config) + + assert mock_request.called + assert "https://example.url/?" 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] + + +def test_transmitter_get_config_from_global_client(mocker): + mock_request = mocker.patch("requests.post") + + Client(**options) + + transmit("https://example.url/") + + assert mock_request.called + assert "https://example.url/?" 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] + + +def test_transmitter_send_body_as_json(mocker): + mock_request = mocker.patch("requests.post") + + transmit("https://example.url/", json={"key": "value"}, config=config) + + assert mock_request.called + assert mock_request.call_args[1]["json"] == {"key": "value"} + + +def test_transmitter_set_proxies_from_config(mocker): + mock_request = mocker.patch("requests.post") + + transmit("https://example.url/", config=config) + + assert mock_request.called + assert mock_request.call_args[1]["proxies"] == {} + + mock_request = mocker.patch("requests.post") + + with_proxy = Config(Options(**options, http_proxy="http://proxy.local:1234")) + transmit("https://example.url/", config=with_proxy) + + assert mock_request.called + assert mock_request.call_args[1]["proxies"] == { + "http": "http://proxy.local:1234", + "https": "http://proxy.local:1234", + } + + +def test_transmitter_set_cert_from_config(mocker): + mock_request = mocker.patch("requests.post") + + transmit("https://example.url/", config=config) + + assert mock_request.called + assert mock_request.call_args[1]["verify"].endswith("/cacert.pem") + + with_ca = Config(Options(**options, ca_file_path="path/to/ca.pem")) + + transmit("https://example.url/", config=with_ca) + + assert mock_request.called + assert mock_request.call_args[1]["verify"] == "path/to/ca.pem" From e6f695197c24cee32cd620beca85478612e504a0 Mon Sep 17 00:00:00 2001 From: Noemi <45180344+unflxw@users.noreply.github.com> Date: Tue, 9 Apr 2024 13:32:24 +0200 Subject: [PATCH 3/5] Remove empty `TYPE_CHECKING` block Probably a leftover from some typing refactor. --- src/appsignal/config.py | 15 +-------------- 1 file changed, 1 insertion(+), 14 deletions(-) diff --git a/src/appsignal/config.py b/src/appsignal/config.py index e0a5718..37e20c1 100644 --- a/src/appsignal/config.py +++ b/src/appsignal/config.py @@ -3,24 +3,11 @@ import os import platform import tempfile -from typing import ( - TYPE_CHECKING, - Any, - ClassVar, - List, - Literal, - TypedDict, - cast, - get_args, -) +from typing import Any, ClassVar, List, Literal, TypedDict, cast, get_args from .__about__ import __version__ -if TYPE_CHECKING: - pass - - class Options(TypedDict, total=False): active: bool | None app_path: str | None From 85ae450af200bd8e39066fe1700de13da854bdce Mon Sep 17 00:00:00 2001 From: Noemi <45180344+unflxw@users.noreply.github.com> Date: Tue, 9 Apr 2024 13:35:14 +0200 Subject: [PATCH 4/5] Implement heartbeats for Python Implement the heartbeats functionality for Python, similarly to how it was implemented for Ruby, Elixir and Node.js. Due to limitations on how `with` blocks are implemented, which make it impossible for a function to behave in different ways when called "by itself" and when called by a `with` block, it is possible to call it in the following ways: - `heartbeat(name)`, which sends a finish heartbeat event - `heartbeat(name, function)`, which sends: - a start heartbeat event - a finish heartbeat event, if the function does not raise - as a `with Heartbeat(name):` block (note: `H` not `h`!) which sends: - a start heartbeat event - a finish heartbeat event, if the `with` block does not raise The latter is intentionally un-documented for now, to avoid potential confusion between `heartbeat` and `Heartbeat`. Add a `LOGGING_ENDPOINT` configuration variable to configure the logging endpoint, which is also the endpoint to which heartbeats are sent. This is a bit bizarre, as Python does not support the logging feature yet, but it is consistent with our other integrations. --- .changesets/implement-heartbeats.md | 28 +++++ conftest.py | 4 +- pyproject.toml | 1 + src/appsignal/__init__.py | 3 + src/appsignal/client.py | 1 + src/appsignal/config.py | 4 + src/appsignal/heartbeat.py | 88 ++++++++++++++ tests/diagnose | 2 +- tests/test_heartbeat.py | 175 ++++++++++++++++++++++++++++ 9 files changed, 304 insertions(+), 2 deletions(-) create mode 100644 .changesets/implement-heartbeats.md create mode 100644 src/appsignal/heartbeat.py create mode 100644 tests/test_heartbeat.py diff --git a/.changesets/implement-heartbeats.md b/.changesets/implement-heartbeats.md new file mode 100644 index 0000000..469b02c --- /dev/null +++ b/.changesets/implement-heartbeats.md @@ -0,0 +1,28 @@ +--- +bump: "patch" +type: "add" +--- + +Add heartbeats support. You can use the `heartbeat` function to send heartbeats directly from your code, to track the execution of certain processes: + +```python +from appsignal import heartbeat + +def send_invoices(): + # ... your code here ... + heartbeat("send_invoices") +``` + +It is also possible to pass a defined function as an argument to the `heartbeat` +function: + +```python +def send_invoices(): + # ... your code here ... + +heartbeat("send_invoices", send_invoices) +``` + +If an exception is raised within the function, the finish event will not be +reported to AppSignal, triggering a notification about the missing heartbeat. +The exception will be raised outside of the heartbeat function. diff --git a/conftest.py b/conftest.py index 1d63b11..81730eb 100644 --- a/conftest.py +++ b/conftest.py @@ -17,8 +17,8 @@ from appsignal import probes from appsignal.agent import agent -from appsignal.opentelemetry import METRICS_PREFERRED_TEMPORALITY from appsignal.client import _reset_client +from appsignal.opentelemetry import METRICS_PREFERRED_TEMPORALITY @pytest.fixture(scope="function", autouse=True) @@ -100,10 +100,12 @@ def stop_and_clear_probes_after_tests(): def reset_agent_active_state(): agent.active = False + @pytest.fixture(scope="function", autouse=True) def reset_global_client(): _reset_client() + @pytest.fixture(scope="function", autouse=True) def stop_agent(): tmp_path = "/tmp" if platform.system() == "Darwin" else tempfile.gettempdir() diff --git a/pyproject.toml b/pyproject.toml index d69035f..647372f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -91,6 +91,7 @@ dependencies = [ "types-deprecated", "types-requests", "django-stubs", + "pytest", ] [tool.hatch.envs.lint.scripts] diff --git a/src/appsignal/__init__.py b/src/appsignal/__init__.py index b0af7ab..37c206d 100644 --- a/src/appsignal/__init__.py +++ b/src/appsignal/__init__.py @@ -4,6 +4,7 @@ from runpy import run_path from .client import Client as Appsignal +from .heartbeat import Heartbeat, heartbeat from .metrics import add_distribution_value, increment_counter, set_gauge from .tracing import ( send_error, @@ -42,6 +43,8 @@ "increment_counter", "set_gauge", "add_distribution_value", + "Heartbeat", + "heartbeat", "start", ] diff --git a/src/appsignal/client.py b/src/appsignal/client.py index 371b7bc..62f048b 100644 --- a/src/appsignal/client.py +++ b/src/appsignal/client.py @@ -17,6 +17,7 @@ _client: Client | None = None + def _reset_client() -> None: global _client _client = None diff --git a/src/appsignal/config.py b/src/appsignal/config.py index 37e20c1..3ba57ac 100644 --- a/src/appsignal/config.py +++ b/src/appsignal/config.py @@ -37,6 +37,7 @@ class Options(TypedDict, total=False): log: str | None log_level: str | None log_path: str | None + logging_endpoint: str | None opentelemetry_port: str | int | None name: str | None push_api_key: str | None @@ -77,6 +78,7 @@ class Config: files_world_accessible=True, log="file", log_level="info", + logging_endpoint="https://appsignal-endpoint.net", opentelemetry_port=8099, send_environment_metadata=True, send_params=True, @@ -172,6 +174,7 @@ def load_from_environment() -> Options: log=os.environ.get("APPSIGNAL_LOG"), log_level=os.environ.get("APPSIGNAL_LOG_LEVEL"), log_path=os.environ.get("APPSIGNAL_LOG_PATH"), + logging_endpoint=os.environ.get("APPSIGNAL_LOGGING_ENDPOINT"), opentelemetry_port=os.environ.get("APPSIGNAL_OPENTELEMETRY_PORT"), name=os.environ.get("APPSIGNAL_APP_NAME"), push_api_key=os.environ.get("APPSIGNAL_PUSH_API_KEY"), @@ -239,6 +242,7 @@ def set_private_environ(self) -> None: "_APPSIGNAL_LOG": options.get("log"), "_APPSIGNAL_LOG_LEVEL": options.get("log_level"), "_APPSIGNAL_LOG_FILE_PATH": self.log_file_path(), + "_APPSIGNAL_LOGGING_ENDPOINT": options.get("logging_endpoint"), "_APPSIGNAL_OPENTELEMETRY_PORT": options.get("opentelemetry_port"), "_APPSIGNAL_PUSH_API_KEY": options.get("push_api_key"), "_APPSIGNAL_PUSH_API_ENDPOINT": options.get("endpoint"), diff --git a/src/appsignal/heartbeat.py b/src/appsignal/heartbeat.py new file mode 100644 index 0000000..0516e2e --- /dev/null +++ b/src/appsignal/heartbeat.py @@ -0,0 +1,88 @@ +from __future__ import annotations + +import logging +from binascii import hexlify +from os import urandom +from time import time +from typing import Any, Callable, Literal, TypedDict, TypeVar, Union + +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 Heartbeat: + 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() + logger = logging.getLogger("appsignal") + + if not config.is_active(): + logger.debug("AppSignal not active, not transmitting heartbeat event") + return + + 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 = Heartbeat(name) + output = None + + if fn is not None: + heartbeat.start() + output = fn() + + heartbeat.finish() + return output diff --git a/tests/diagnose b/tests/diagnose index 31e13d7..152d1e0 160000 --- a/tests/diagnose +++ b/tests/diagnose @@ -1 +1 @@ -Subproject commit 31e13d704577a138cfaba76976f843603d070b5a +Subproject commit 152d1e033c239742f9d58cefd4a830c8cdd778a6 diff --git a/tests/test_heartbeat.py b/tests/test_heartbeat.py new file mode 100644 index 0000000..d747ea5 --- /dev/null +++ b/tests/test_heartbeat.py @@ -0,0 +1,175 @@ +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" From 6ac44b73af0f2351ca914f2499500e9073a9bd0a Mon Sep 17 00:00:00 2001 From: Noemi <45180344+unflxw@users.noreply.github.com> Date: Mon, 22 Apr 2024 14:33:15 +0200 Subject: [PATCH 5/5] Amend changeset to minor bump Bump the changeset to a minor version and mention that the feature is only available to beta testers. --- .changesets/implement-heartbeats.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.changesets/implement-heartbeats.md b/.changesets/implement-heartbeats.md index 469b02c..c741843 100644 --- a/.changesets/implement-heartbeats.md +++ b/.changesets/implement-heartbeats.md @@ -1,8 +1,12 @@ --- -bump: "patch" +bump: "minor" type: "add" --- +_Heartbeats are currently only available to beta testers. If you are interested in trying it out, [send an email to support@appsignal.com](mailto:support@appsignal.com?subject=Heartbeat%20beta)!_ + +--- + Add heartbeats support. You can use the `heartbeat` function to send heartbeats directly from your code, to track the execution of certain processes: ```python