-
Notifications
You must be signed in to change notification settings - Fork 2
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
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.
- Loading branch information
Showing
9 changed files
with
304 additions
and
2 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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. |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -17,6 +17,7 @@ | |
|
||
_client: Client | None = None | ||
|
||
|
||
def _reset_client() -> None: | ||
global _client | ||
_client = None | ||
|
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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" |