Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changesets/support-logging.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
bump: minor
type: add
---

Support logging through the external collector experimental feature. When the `collector_endpoint` configuration option is provided, the OpenTelemetry stack will be automatically configured to instrument logs.

The `logging` module will be automatically instrumented, such that log lines emitted through loggers that propagate to the root logger will be automatically sent to AppSignal. To disable this behaviour, add `"logging"` to the `disable_default_instrumentations` configuration option list.
14 changes: 14 additions & 0 deletions .changesets/support-usage-with-external-collector.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
bump: minor
type: add
---

Support usage with external collector. When the `collector_endpoint` configuration option is provided, instead of booting up the AppSignal agent bundled with the application, the OpenTelemetry stack will be configured to send data to the given collector.

This is an **experimental** feature. The following functionality is not currently supported when using the collector:

- NGINX metrics
- StatsD metrics
- Host metrics

Some configuration options are only supported when using the agent or when using the collector. A warning will be emitted if a configuration option that is only supported by one is set while using the other.
6 changes: 0 additions & 6 deletions conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@
from opentelemetry.trace import set_tracer_provider

from appsignal import probes
from appsignal.agent import agent
from appsignal.check_in.heartbeat import (
_kill_continuous_heartbeats,
_reset_heartbeat_continuous_interval_seconds,
Expand Down Expand Up @@ -105,11 +104,6 @@ def stop_and_clear_probes_after_tests() -> Any:
probes.clear()


@pytest.fixture(scope="function", autouse=True)
def reset_agent_active_state() -> Any:
agent.active = False
Comment on lines -108 to -110
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We don't need to reset this anymore between tests?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think not! There's not an agent global anymore -- instead, each client has its own agent instance. We do reset the global client between tests, so the new one that is initialised should be in a clean state.



@pytest.fixture(scope="function", autouse=True)
def reset_global_client() -> Any:
_reset_client()
Expand Down
30 changes: 24 additions & 6 deletions src/appsignal/agent.py
Original file line number Diff line number Diff line change
@@ -1,18 +1,27 @@
from __future__ import annotations

import os
import signal
import subprocess
import time
from dataclasses import dataclass
from pathlib import Path

from . import internal_logger as logger
from .binary import Binary
from .config import Config


@dataclass
class Agent:
class Agent(Binary):
package_path: Path = Path(__file__).parent
agent_path: Path = package_path / "appsignal-agent"
platform_path: Path = package_path / "_appsignal_platform"
active: bool = False
_active: bool = False

@property
def active(self) -> bool:
return self._active

def start(self, config: Config) -> None:
config.set_private_environ()
Expand All @@ -33,12 +42,24 @@ def start(self, config: Config) -> None:
p.wait(timeout=1)
returncode = p.returncode
if returncode == 0:
self.active = True
self._active = True
else:
output, _ = p.communicate()
out = output.decode("utf-8")
print(f"AppSignal agent is unable to start ({returncode}): ", out)

def stop(self, config: Config) -> None:
working_dir = config.option("working_directory_path") or "/tmp/appsignal"
lock_path = os.path.join(working_dir, "agent.lock")
try:
with open(lock_path) as file:
line = file.readline()
pid = int(line.split(";")[2])
os.kill(pid, signal.SIGTERM)
time.sleep(2)
except FileNotFoundError:
logger.info("Agent lock file not found; not stopping the agent")

def diagnose(self, config: Config) -> bytes:
config.set_private_environ()
return subprocess.run(
Expand All @@ -56,6 +77,3 @@ def architecture_and_platform(self) -> list[str]:
return file.read().split("-", 1)
except FileNotFoundError:
return ["", ""]


agent = Agent()
32 changes: 32 additions & 0 deletions src/appsignal/binary.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
from __future__ import annotations

from abc import ABC, abstractmethod

from .config import Config


class Binary(ABC):
@property
@abstractmethod
def active(self) -> bool: ...

@abstractmethod
def start(self, config: Config) -> None: ...

@abstractmethod
def stop(self, config: Config) -> None: ...


class NoopBinary(Binary):
def __init__(self, active: bool = False) -> None:
self._active = active

@property
def active(self) -> bool:
return self._active

def start(self, config: Config) -> None:
pass

def stop(self, config: Config) -> None:
pass
42 changes: 26 additions & 16 deletions src/appsignal/client.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,9 @@
from __future__ import annotations

import os
import signal
import time
from typing import TYPE_CHECKING

from . import internal_logger as logger
from .agent import agent
from .binary import NoopBinary
from .config import Config, Options
from .opentelemetry import start as start_opentelemetry
from .probes import start as start_probes
Expand All @@ -15,6 +12,8 @@
if TYPE_CHECKING:
from typing_extensions import Unpack

from .binary import Binary


_client: Client | None = None

Expand All @@ -26,11 +25,13 @@ def _reset_client() -> None:

class Client:
_config: Config
_binary: Binary

def __init__(self, **options: Unpack[Options]) -> None:
global _client

self._config = Config(options)
self._binary = NoopBinary()
_client = self

@classmethod
Expand All @@ -41,10 +42,13 @@ def config(cls) -> Config | None:
return _client._config

def start(self) -> None:
self._set_binary()

if self._config.is_active():
logger.info("Starting AppSignal")
agent.start(self._config)
if not agent.active:
self._config.warn()
self._binary.start(self._config)
if not self._binary.active:
return
start_opentelemetry(self._config)
self._start_probes()
Expand All @@ -56,17 +60,23 @@ def stop(self) -> None:

logger.info("Stopping AppSignal")
scheduler().stop()
working_dir = self._config.option("working_directory_path") or "/tmp/appsignal"
lock_path = os.path.join(working_dir, "agent.lock")
try:
with open(lock_path) as file:
line = file.readline()
pid = int(line.split(";")[2])
os.kill(pid, signal.SIGTERM)
time.sleep(2)
except FileNotFoundError:
logger.info("Agent lock file not found")
self._binary.stop(self._config)

def _start_probes(self) -> None:
if self._config.option("enable_minutely_probes"):
start_probes()

def _set_binary(self) -> None:
if self._config.should_use_external_collector():
# When a custom collector endpoint is set, use a `NoopBinary`
# set to active, so that OpenTelemetry and probes are started,
# but the agent is not started.
logger.info(
"Not starting the AppSignal agent: using collector endpoint instead"
)
self._binary = NoopBinary(active=True)
else:
# Use the agent when a custom collector endpoint is not set.
from .agent import Agent

self._binary = Agent()
Loading
Loading