-
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, implement two different heartbeat convenience functions, `heartbeat_with_context` and `heartbeat`. The naming convention follows that of our existing `send_error` and `send_error_with_context` helpers. 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
8 changed files
with
245 additions
and
1 deletion.
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,31 @@ | ||
--- | ||
bump: "patch" | ||
type: "add" | ||
--- | ||
|
||
Add heartbeats support. You can send heartbeats directly from your code, to | ||
track the execution of certain processes: | ||
|
||
```python | ||
from appsignal import heartbeat | ||
|
||
def sendInvoices(): | ||
# ... your code here ... | ||
heartbeat("send_invoices") | ||
``` | ||
|
||
You can use `heartbeat_with_context` with a `with` block, to report to | ||
AppSignal both when the process starts, and when it finishes, allowing you | ||
to see the duration of the process: | ||
|
||
```python | ||
from appsignal import heartbeat_with_context | ||
|
||
def sendInvoices(): | ||
with heartbeat_with_context("send_invoices"): | ||
# ... your code here ... | ||
``` | ||
|
||
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,80 @@ | ||
from __future__ import annotations | ||
|
||
import logging | ||
from binascii import hexlify | ||
from contextlib import contextmanager | ||
from os import urandom | ||
from time import time | ||
from typing import Iterator, Literal, TypedDict, Union | ||
|
||
from .client import Client | ||
from .config import Config | ||
from .transmitter import transmit | ||
|
||
|
||
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 heartbeat(name: str) -> None: | ||
Heartbeat(name).finish() | ||
|
||
|
||
@contextmanager | ||
def heartbeat_with_context(name: str) -> Iterator[None]: | ||
Heartbeat(name).start() | ||
|
||
try: | ||
yield | ||
except Exception as e: | ||
raise e | ||
else: | ||
Heartbeat(name).finish() |
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,121 @@ | ||
from time import sleep | ||
|
||
from pytest import raises | ||
|
||
from appsignal.client import Client | ||
from appsignal.heartbeat import Heartbeat, heartbeat, heartbeat_with_context | ||
|
||
|
||
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" | ||
|
||
|
||
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" | ||
|
||
|
||
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_context_sends_heartbeat_start_and_finish_event(mocker): | ||
mock_request = mocker.patch("requests.post") | ||
init_client() | ||
|
||
with heartbeat_with_context("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"] | ||
) | ||
|
||
|
||
def test_heartbeat_with_context_does_not_send_heartbeat_finish_event_on_exception( | ||
mocker, | ||
): | ||
mock_request = mocker.patch("requests.post") | ||
init_client() | ||
|
||
with raises(Exception, match="Whoops!"): | ||
with heartbeat_with_context("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" |