Skip to content
Open
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
48 changes: 30 additions & 18 deletions solnlib/observability.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@
import urllib.request
from typing import Callable, ClassVar, Optional, Union
from ._observability_flush import _register_controller
from .splunkenv import get_conf_stanzas
from .splunkenv import _get_running_app_name, get_conf_stanzas
from opentelemetry.metrics import Instrument, Meter
from opentelemetry.sdk.metrics import MeterProvider, Counter, Histogram
from opentelemetry.sdk.metrics.export import (
Expand Down Expand Up @@ -392,12 +392,14 @@ def __init__(
logger: Python logger (or ``LoggerAdapter``) for all diagnostic
output. Typically the caller's own module-level logger.
ta_name: Add-on identifier, e.g. ``"Splunk_TA_myapp"``. When
*None* the value is read from the ``[id]`` stanza of
``app.conf`` via :func:`~solnlib.splunkenv.get_conf_stanzas`.
Pass it explicitly when the add-on runs outside a full Splunk
environment or to avoid the ``app.conf`` lookup.
*None* the value is read from the ``[id]`` stanza of the
``app.conf`` of the running app (detected from the main
script's path, then solnlib's own path; falling back to the
app directory name).
Pass it explicitly when the add-on runs outside a standard
``etc/apps/<app>`` layout or to avoid the ``app.conf`` lookup.
ta_version: Add-on version string, e.g. ``"3.1.0"``. When *None*
the value is read from the ``[launcher]`` stanza of
the value is read from the ``[launcher]`` stanza of the same
``app.conf``. Falls back to ``"unknown"`` if it cannot be
determined.
extra_exporters: Optional list of additional
Expand Down Expand Up @@ -433,8 +435,8 @@ def __init__(

if not ta_name:
raise ValueError(
"ta_name could not be determined: pass it explicitly or ensure "
"app.conf is readable via btool"
"ta_name could not be determined: pass it explicitly or run "
"the add-on from etc/apps/<app>"
)

resource = Resource(
Expand Down Expand Up @@ -504,19 +506,29 @@ def __init__(
)

def _read_ta_info(self) -> tuple[Optional[str], Optional[str]]:
"""Read the add-on name and version from ``app.conf``.
"""Read the add-on name and version from the owning app's ``app.conf``.

Returns a ``(ta_name, ta_version)`` tuple. Either value is
``None`` when the corresponding key is missing or when
``app.conf`` cannot be read (e.g. outside a Splunk environment).
The owning app is the one whose script is running
(``etc/apps/<app>/bin/...``), or failing that the one that bundles
solnlib. The lookup is always scoped to that app: an unscoped
``app.conf`` merge can return another installed add-on's ``[id]``
stanza.

Returns a ``(ta_name, ta_version)`` tuple. Both are ``None`` when the
owning app cannot be detected or ``app.conf`` cannot be read; the
version alone is ``None`` when ``[launcher] version`` is missing.
"""
try:
stanzas = get_conf_stanzas("app")
ta_name = stanzas.get("id", {}).get("name") or None
scoped_stanzas = (
get_conf_stanzas("app", app_name=ta_name) if ta_name else stanzas
)
ta_version = scoped_stanzas.get("launcher", {}).get("version") or None
app_name = _get_running_app_name()
if not app_name:
self._logger.info(
"Could not determine the running add-on from the script "
"or solnlib path; app.conf not read"
)
return None, None
stanzas = get_conf_stanzas("app", app_name=app_name)
ta_name = stanzas.get("id", {}).get("name") or app_name
ta_version = stanzas.get("launcher", {}).get("version") or None
return ta_name, ta_version
except Exception as error:
self._logger.info(
Expand Down
62 changes: 62 additions & 0 deletions solnlib/splunkenv.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,11 @@
import os.path as op
import socket
import subprocess
import sys
import json
from configparser import ConfigParser
from io import StringIO
from pathlib import Path
from typing import Optional, Union
import __main__
from solnlib._settings import use_btool
Expand Down Expand Up @@ -66,6 +68,7 @@ def msp(*args, **kwargs):
ETC_LEAF = "etc"
APP_SYSTEM = "system"
APP_HEC = "splunk_httpinput"
_APPS_DIRS = ("apps", "peer-apps", "slave-apps")


class SessionKeyNotFound(Exception):
Expand All @@ -92,6 +95,65 @@ def make_splunkhome_path(parts: Union[list, tuple]) -> str:
return msp(parts)


def _is_splunk_etc_dir(path: Path) -> bool:
if op.normcase(path.name) == ETC_LEAF:
return True
splunk_etc = os.environ.get("SPLUNK_ETC")
return bool(splunk_etc) and op.normcase(op.realpath(splunk_etc)) == op.normcase(
op.realpath(path)
)


def _get_app_name_from_path(path: str) -> Optional[str]:
"""Return the name of the Splunk app that contains `path`.

The app is the directory directly under ``$SPLUNK_ETC/apps`` (or
``peer-apps`` / ``slave-apps`` on indexer cluster peers). For example,
``$SPLUNK_HOME/etc/apps/Splunk_TA_test/bin/input.py`` resolves to
``"Splunk_TA_test"``.

Arguments:
path: File system path to inspect.

Returns:
App name, or None if `path` is not inside a Splunk apps directory.
"""
# Not utils.get_appname_from_path: that one returns "-", ignores
# SPLUNK_ETC and slave-apps, and is public, so its behaviour is kept.
try:
# abspath, not resolve(): apps symlinked into etc/apps keep their name.
absolute = Path(op.abspath(path))
# Outermost match first: Splunk's etc/apps sits above anything an app
# vendors that happens to contain etc/apps.
for candidate in reversed((absolute, *absolute.parents)):
parent = candidate.parent
if op.normcase(parent.name) in _APPS_DIRS and _is_splunk_etc_dir(
parent.parent
):
return candidate.name
except (OSError, ValueError):
pass
return None


def _get_running_app_name() -> Optional[str]:
"""Return the name of the Splunk app whose script is running.

Tries the main script first (modular inputs, alert actions and search
commands run from ``etc/apps/<app>/bin``; this path also keeps
symlinked app directories intact), then falls back to the app that
bundles solnlib.

Returns:
App name, or None if neither path is inside a Splunk apps directory.
"""
argv = getattr(sys, "argv", None)
main_path = getattr(__main__, "__file__", None) or (argv[0] if argv else None)
return (main_path and _get_app_name_from_path(main_path)) or (
_get_app_name_from_path(__file__)
)


def get_splunk_host_info(session_key: Optional[str] = None) -> tuple:
"""Get splunk host info.

Expand Down
114 changes: 114 additions & 0 deletions tests/unit/test_observability.py
Original file line number Diff line number Diff line change
Expand Up @@ -765,6 +765,9 @@ def test_register_instrument_calls_callback(self, logger, monkeypatch):

def test_read_ta_info_returns_values(self, logger, monkeypatch):
# Arrange
monkeypatch.setattr(
"solnlib.observability._get_running_app_name", lambda: "my_ta"
)
monkeypatch.setattr(
"solnlib.observability.get_conf_stanzas",
lambda conf, **kwargs: {
Expand All @@ -781,8 +784,116 @@ def test_read_ta_info_returns_values(self, logger, monkeypatch):
# Assert
assert svc._meter is not None

def test_read_ta_info_always_scoped_to_owning_app(self, logger, monkeypatch):
# Arrange
monkeypatch.setattr(
"solnlib.observability._get_running_app_name",
lambda: "Splunk_TA_google-cloudplatform",
)
get_conf = MagicMock(
return_value={
"id": {"name": "Splunk_TA_google-cloudplatform"},
"launcher": {"version": "5.0.0"},
}
)
monkeypatch.setattr("solnlib.observability.get_conf_stanzas", get_conf)
svc = _make_service(logger, monkeypatch)
# Act
result = svc._read_ta_info()
# Assert
assert result == ("Splunk_TA_google-cloudplatform", "5.0.0")
get_conf.assert_called_once_with(
"app", app_name="Splunk_TA_google-cloudplatform"
)

def test_read_ta_info_reports_owning_app_when_other_apps_installed(
self, logger, monkeypatch
):
# Arrange: an unscoped lookup would return Splunk_TA_cyberark's [id].
apps = {
"Splunk_TA_cyberark": {
"id": {"name": "Splunk_TA_cyberark"},
"launcher": {"version": "1.2.0"},
},
"Splunk_TA_google-cloudplatform": {
"id": {"name": "Splunk_TA_google-cloudplatform"},
"launcher": {"version": "5.0.0"},
},
}

def fake_get_conf_stanzas(conf, app_name=None):
if app_name is None:
return apps["Splunk_TA_cyberark"]
return apps[app_name]

monkeypatch.setattr(
"solnlib.observability.get_conf_stanzas", fake_get_conf_stanzas
)
monkeypatch.setattr(
"solnlib.observability._get_running_app_name",
lambda: "Splunk_TA_google-cloudplatform",
)
svc = _make_service(logger, monkeypatch)
# Act / Assert
assert svc._read_ta_info() == ("Splunk_TA_google-cloudplatform", "5.0.0")

def test_read_ta_info_missing_id_falls_back_to_app_dir(self, logger, monkeypatch):
# Arrange
monkeypatch.setattr(
"solnlib.observability._get_running_app_name", lambda: "Splunk_TA_aws"
)
monkeypatch.setattr(
"solnlib.observability.get_conf_stanzas",
lambda conf, **kwargs: {"launcher": {"version": "7.0.0"}},
)
svc = _make_service(logger, monkeypatch)
# Act / Assert
assert svc._read_ta_info() == ("Splunk_TA_aws", "7.0.0")

def test_read_ta_info_no_detectable_app_returns_none(self, logger, monkeypatch):
# Arrange
monkeypatch.setattr("solnlib.observability._get_running_app_name", lambda: None)
get_conf = MagicMock()
monkeypatch.setattr("solnlib.observability.get_conf_stanzas", get_conf)
svc = _make_service(logger, monkeypatch)
logger.reset_mock()
# Act
result = svc._read_ta_info()
# Assert
assert result == (None, None)
get_conf.assert_not_called()
logger.info.assert_called()
logger.warning.assert_not_called()

def test_no_detectable_app_skips_telemetry(self, logger, monkeypatch):
# Arrange
monkeypatch.setattr("solnlib.observability._get_running_app_name", lambda: None)
monkeypatch.setattr(
"solnlib.observability.ObservabilityService._create_otlp_exporter",
lambda self: None,
)
# Act
svc = ObservabilityService(modinput_type="test-input", logger=logger)
# Assert
assert svc._meter is None
logger.warning.assert_not_called()

def test_read_ta_info_app_detection_error_returns_none(self, logger, monkeypatch):
monkeypatch.setattr(
"solnlib.observability._get_running_app_name",
MagicMock(side_effect=RuntimeError("boom")),
)
svc = _make_service(logger, monkeypatch)
logger.reset_mock()
assert svc._read_ta_info() == (None, None)
logger.info.assert_called()
logger.warning.assert_not_called()

def test_read_ta_info_handles_exception(self, logger, monkeypatch):
# Arrange
monkeypatch.setattr(
"solnlib.observability._get_running_app_name", lambda: "my_ta"
)
monkeypatch.setattr(
"solnlib.observability.ObservabilityService._create_otlp_exporter",
lambda self: None,
Expand Down Expand Up @@ -1516,6 +1627,9 @@ def test_read_ta_info_exception_uses_safe_str(self, logger, monkeypatch):
monkeypatch.setattr(
"solnlib.observability._safe_exception_str", lambda error: "SAFE"
)
monkeypatch.setattr(
"solnlib.observability._get_running_app_name", lambda: "my_ta"
)
monkeypatch.setattr(
"solnlib.observability.ObservabilityService._create_otlp_exporter",
lambda self: None,
Expand Down
Loading
Loading