Skip to content

Commit

Permalink
Implement heartbeats for Python
Browse files Browse the repository at this point in the history
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
unflxw committed Apr 12, 2024
1 parent e6f6951 commit 7d46b00
Show file tree
Hide file tree
Showing 8 changed files with 245 additions and 1 deletion.
31 changes: 31 additions & 0 deletions .changesets/implement-heartbeats.md
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.
4 changes: 3 additions & 1 deletion conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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()
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ dependencies = [
"types-deprecated",
"types-requests",
"django-stubs",
"pytest",
]

[tool.hatch.envs.lint.scripts]
Expand Down
4 changes: 4 additions & 0 deletions src/appsignal/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from runpy import run_path

from .client import Client as Appsignal
from .heartbeat import Heartbeat, heartbeat, heartbeat_with_context
from .metrics import add_distribution_value, increment_counter, set_gauge
from .tracing import (
send_error,
Expand Down Expand Up @@ -42,6 +43,9 @@
"increment_counter",
"set_gauge",
"add_distribution_value",
"Heartbeat",
"heartbeat",
"heartbeat_with_context",
"start",
]

Expand Down
1 change: 1 addition & 0 deletions src/appsignal/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@

_client: Client | None = None


def _reset_client() -> None:
global _client
_client = None
Expand Down
4 changes: 4 additions & 0 deletions src/appsignal/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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"),
Expand Down Expand Up @@ -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"),
Expand Down
80 changes: 80 additions & 0 deletions src/appsignal/heartbeat.py
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()
121 changes: 121 additions & 0 deletions tests/test_heartbeat.py
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"

0 comments on commit 7d46b00

Please sign in to comment.