diff --git a/docker-compose.yml b/docker-compose.yml index c29a3397..6d361bcf 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,6 +2,7 @@ services: splunk: image: "splunk/splunk:${SPLUNK_VERSION}" container_name: splunk + platform: linux/amd64 environment: - SPLUNK_START_ARGS=--accept-license - SPLUNK_GENERAL_TERMS=--accept-sgt-current-at-splunk-com diff --git a/example.py b/example.py new file mode 100644 index 00000000..6befc8e6 --- /dev/null +++ b/example.py @@ -0,0 +1,6 @@ +from splunklib import client + +service = client.connect(host="localhost", username="admin", password="changed!", autologin=True) + +for app in service.apps: + print(app.setupInfo) diff --git a/splunklib/client.py b/splunklib/client.py index 2c4b3ea8..7c0d20c9 100644 --- a/splunklib/client.py +++ b/splunklib/client.py @@ -68,6 +68,8 @@ from time import sleep from urllib import parse +from splunklib.internal.telemetry.sdk_usage import log_telemetry_sdk_usage + from . import data from .data import record from .binding import ( @@ -355,6 +357,7 @@ def connect(**kwargs): """ s = Service(**kwargs) s.login() + log_telemetry_sdk_usage(s, module="custom_script") return s diff --git a/splunklib/internal/__init__.py b/splunklib/internal/__init__.py new file mode 100644 index 00000000..1501c6fb --- /dev/null +++ b/splunklib/internal/__init__.py @@ -0,0 +1,13 @@ +# Copyright © 2011-2025 Splunk, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"): you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. diff --git a/splunklib/internal/telemetry/__init__.py b/splunklib/internal/telemetry/__init__.py new file mode 100644 index 00000000..277bb905 --- /dev/null +++ b/splunklib/internal/telemetry/__init__.py @@ -0,0 +1,17 @@ +# Copyright © 2011-2025 Splunk, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"): you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from .sender import TelemetrySender +from .metric import Metric, MetricType +from .sdk_usage import log_telemetry_sdk_usage diff --git a/splunklib/internal/telemetry/metric.py b/splunklib/internal/telemetry/metric.py new file mode 100644 index 00000000..c5a8e5d5 --- /dev/null +++ b/splunklib/internal/telemetry/metric.py @@ -0,0 +1,30 @@ +# Copyright © 2011-2025 Splunk, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"): you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from dataclasses import dataclass +from enum import Enum +from typing import Dict + + +class MetricType(Enum): + Event = "event" + Aggregate = "aggregate" + + +@dataclass +class Metric: + type: MetricType + component: str + data: Dict + opt_in_required: int # TODO: find what the values mean diff --git a/splunklib/internal/telemetry/sdk_usage.py b/splunklib/internal/telemetry/sdk_usage.py new file mode 100644 index 00000000..eb61ecad --- /dev/null +++ b/splunklib/internal/telemetry/sdk_usage.py @@ -0,0 +1,35 @@ +import sys +import splunklib +from .metric import Metric, MetricType +from .sender import TelemetrySender + +import logging +from splunklib import setup_logging + +setup_logging(logging.DEBUG) + +log = logging.getLogger() + +SDK_USAGE_COMPONENT = "splunk.sdk.usage" + + +# FIXME: adding Service typehint produces circular dependency +def log_telemetry_sdk_usage(service, **kwargs): + metric = Metric( + MetricType.Event, + SDK_USAGE_COMPONENT, + { + "sdk-language": "python", + "python-version": sys.version, + "sdk-version": splunklib.__version__, + **kwargs, + }, + 4, + ) + try: + log.debug(f"sending new telemetry {metric}") + telemetry = TelemetrySender(service) + # TODO: handle possible errors + _, _ = telemetry.send(metric) + except Exception as e: + log.error("Could not send telemetry", exc_info=True) diff --git a/splunklib/internal/telemetry/sender.py b/splunklib/internal/telemetry/sender.py new file mode 100644 index 00000000..7dafdb67 --- /dev/null +++ b/splunklib/internal/telemetry/sender.py @@ -0,0 +1,70 @@ +# Copyright © 2011-2025 Splunk, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"): you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from typing import Any, Optional, Tuple +from splunklib.data import Record +from splunklib.internal.telemetry.metric import Metric +import json + +# TODO: decide: either struggle with the type hints or get rid of them and stick to the convention + +CONTENT_TYPE = [('Content-Type', 'application/json')] +DEFAULT_TELEMETRY_USER = "nobody" # User `nobody` always exists +DEFAULT_TELEMETRY_APP = "splunk_instrumentation" # This app is shipped with Splunk and has `telemetry-metric` endpoint +TELEMETRY_ENDPOINT = "telemetry-metric" + + +class TelemetrySender: + # FIXME: adding Service typehint produces circular dependency + # service: Service + + def __init__(self, service): + self.service = service + + def send(self, metric: Metric, user: Optional[str] = None, app: Optional[str] = None) -> Tuple[Record, Any]: + """Sends the metric to the `telemetry-metric` endpoint. + + :param user: Optional user that sends the telemetry. + :param app: Optional app that is used to send the telemetry. + + If those values are omitted, the default values are used. + This makes sure that, even if missing some info, the event will be sent. + """ + + metric_body = self._metric_to_json(metric) + + user = user or DEFAULT_TELEMETRY_USER + app = app or DEFAULT_TELEMETRY_APP + + response = self.service.post( + "telemetry-metric", + user, + app, + headers=[('Content-Type', 'application/json')], + body=metric_body, + ) + + body = json.loads(response.body.read().decode('utf-8')) + + return response, body + + def _metric_to_json(self, metric: Metric) -> str: + m = { + "type": metric.type.value, + "component": metric.component, + "data": metric.data, + "optInRequired": metric.opt_in_required + } + + return json.dumps(m) diff --git a/splunklib/modularinput/script.py b/splunklib/modularinput/script.py index 89a08edc..82002bcd 100644 --- a/splunklib/modularinput/script.py +++ b/splunklib/modularinput/script.py @@ -22,6 +22,8 @@ from .input_definition import InputDefinition from .validation_definition import ValidationDefinition +from splunklib.internal.telemetry import log_telemetry_sdk_usage + class Script(metaclass=ABCMeta): """An abstract base class for implementing modular inputs. @@ -66,6 +68,7 @@ def run_script(self, args, event_writer, input_stream): self._input_definition = InputDefinition.parse(input_stream) self.stream_events(self._input_definition, event_writer) event_writer.close() + log_telemetry_sdk_usage(self.service, module="modularinput") return 0 if str(args[1]).lower() == "--scheme": diff --git a/splunklib/searchcommands/search_command.py b/splunklib/searchcommands/search_command.py index 2c4f2ab5..6df1b68e 100644 --- a/splunklib/searchcommands/search_command.py +++ b/splunklib/searchcommands/search_command.py @@ -35,6 +35,8 @@ from warnings import warn from xml.etree import ElementTree +from splunklib.internal.telemetry.sdk_usage import log_telemetry_sdk_usage + # Relative imports from . import Boolean, Option, environment from .internals import ( @@ -468,6 +470,9 @@ def process( else: self._process_protocol_v2(argv, ifile, ofile) + cmd_name = self.__class__.__name__ + log_telemetry_sdk_usage(self.service, module=cmd_name) + def _map_input_header(self): metadata = self._metadata searchinfo = metadata.searchinfo diff --git a/tests/system/test_apps/modularinput_app/bin/modularinput.py b/tests/system/test_apps/modularinput_app/bin/modularinput.py index 838b2cf4..c05e5449 100755 --- a/tests/system/test_apps/modularinput_app/bin/modularinput.py +++ b/tests/system/test_apps/modularinput_app/bin/modularinput.py @@ -21,7 +21,6 @@ sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "lib")) from splunklib.modularinput import Scheme, Argument, Script, Event - class ModularInput(Script): endpoint_arg = "endpoint" @@ -47,10 +46,11 @@ def validate_input(self, definition): raise ValueError(f"non-supported scheme {parsed.scheme}") def stream_events(self, inputs, ew): + for input_name, input_item in list(inputs.inputs.items()): event = Event() event.stanza = input_name - event.data = "example message" + event.data = f"New endpoint received: {input_item[self.endpoint_arg]}" ew.write_event(event)