diff --git a/CHANGELOG.md b/CHANGELOG.md index 2a8ccfe..cdee4b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ - **[CHANGE]**: change(build): add doc string check to flake8 [#14](https://github.com/intergral/deep/pull/14) [@Umaaz](https://github.com/Umaaz) - **[FEATURE]**: feat(logging): initial implementation of log points [#3](https://github.com/intergral/deep/pull/3) [@Umaaz](https://github.com/Umaaz) - **[FEATURE]**: feat(plugins): change plugins to allow better customisation [#22](https://github.com/intergral/deep/pull/22) [@Umaaz](https://github.com/Umaaz) +- **[FEATURE]**: feat(metrics): initial implementation of metric points [#21](https://github.com/intergral/deep/pull/21) [@Umaaz](https://github.com/Umaaz) - **[ENHANCEMENT]**: enhancement(trigger): change tracepoint handling to use triggers [#16](https://github.com/intergral/deep/pull/16) [@Umaaz](https://github.com/Umaaz) - **[BUGFIX]**: feat(api): add api function to register tracepoint directly [#8](https://github.com/intergral/deep/pull/8) [@Umaaz](https://github.com/Umaaz) diff --git a/Makefile b/Makefile index f40da87..eb13b17 100644 --- a/Makefile +++ b/Makefile @@ -24,7 +24,7 @@ it-test: .PHONY: coverage coverage: - pytest tests/unit_tests --cov=deep --cov-report term --cov-fail-under=82 --cov-report html --cov-branch + pytest tests/unit_tests --cov=deep --cov-report term --cov-fail-under=83 --cov-report html --cov-branch .PHONY: lint lint: diff --git a/dev-requirements.txt b/dev-requirements.txt index 3e92733..2e693e2 100644 --- a/dev-requirements.txt +++ b/dev-requirements.txt @@ -22,3 +22,4 @@ certifi>=2023.7.22 # not directly required, pinned by Snyk to avoid a vulnerabil setuptools>=65.5.1 # not directly required, pinned by Snyk to avoid a vulnerability opentelemetry-api opentelemetry-sdk +prometheus-client diff --git a/examples/simple-app-metrics/src/simple-app/main.py b/examples/simple-app-metrics/src/simple-app/main.py index 70a9f99..10b78de 100644 --- a/examples/simple-app-metrics/src/simple-app/main.py +++ b/examples/simple-app-metrics/src/simple-app/main.py @@ -31,6 +31,8 @@ from prometheus_client import Summary, start_http_server import deep +from deep.api.tracepoint.constants import FIRE_COUNT +from deep.api.tracepoint.tracepoint_config import MetricDefinition, LabelExpression from simple_test import SimpleTest @@ -80,12 +82,15 @@ def process_request(t): if __name__ == '__main__': start_http_server(8000) - d = deep.start({ + _deep = deep.start({ 'SERVICE_URL': 'localhost:43315', 'SERVICE_SECURE': 'False', }) - d.register_tracepoint("simple_test.py", 31) + _deep.register_tracepoint("simple_test.py", 43, {FIRE_COUNT: '-1'}, [], [ + MetricDefinition(name="simple_test", metric_type="histogram", expression="len(info)", + labels=[LabelExpression("test_name", expression="self.test_name")]) + ]) print("app running") main() diff --git a/src/deep/api/deep.py b/src/deep/api/deep.py index 35ce3dc..4aef952 100644 --- a/src/deep/api/deep.py +++ b/src/deep/api/deep.py @@ -19,6 +19,7 @@ import deep.logging from deep.api.plugin import load_plugins from deep.api.resource import Resource +from deep.api.tracepoint.tracepoint_config import MetricDefinition from deep.config import ConfigService from deep.config.tracepoint_config import TracepointConfigService from deep.grpc import GRPCService @@ -78,11 +79,14 @@ def shutdown(self): self.trigger_handler.shutdown() self.task_handler.flush() self.poll.shutdown() + for plugin in self.config.plugins: + plugin.shutdown() deep.logging.info("Deep is shutdown.") self.started = False def register_tracepoint(self, path: str, line: int, args: Dict[str, str] = None, - watches: List[str] = None, metrics=None) -> 'TracepointRegistration': + watches: List[str] = None, + metrics: List[MetricDefinition] = None) -> 'TracepointRegistration': """ Register a new tracepoint. diff --git a/src/deep/api/plugin/__init__.py b/src/deep/api/plugin/__init__.py index 6017f93..02faf36 100644 --- a/src/deep/api/plugin/__init__.py +++ b/src/deep/api/plugin/__init__.py @@ -32,6 +32,7 @@ DEEP_PLUGINS = [ 'deep.api.plugin.otel.OTelPlugin', 'deep.api.plugin.python.PythonPlugin', + 'deep.api.plugin.metric.prometheus_metrics.PrometheusPlugin', ] """System provided default plugins.""" @@ -111,6 +112,10 @@ def is_active(self) -> bool: return True return str2bool(attr) + def shutdown(self): + """Clean up and shutdown the plugin.""" + pass + def order(self) -> int: """ Order of precedence when multiple versions of providers are available. diff --git a/src/deep/api/plugin/metric/__init__.py b/src/deep/api/plugin/metric/__init__.py new file mode 100644 index 0000000..395d70a --- /dev/null +++ b/src/deep/api/plugin/metric/__init__.py @@ -0,0 +1,86 @@ +# Copyright (C) 2024 Intergral GmbH +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +""" +Definition of metric processor. + +Metric processor gives the ability to attach dynamic metrics to metric providers. +""" + +import abc +from typing import Dict + + +class MetricProcessor(abc.ABC): + """Metric processor connects Deep to a metric provider.""" + + @abc.abstractmethod + def counter(self, name: str, labels: Dict[str, str], namespace: str, help_string: str, unit: str, value: float): + """ + Create a counter type value in the provider. + + :param name: the metric name + :param labels: the metric labels + :param namespace: the metric namespace + :param help_string: the metric help string + :param unit: the metric unit + :param value: the metric value + """ + pass + + @abc.abstractmethod + def gauge(self, name: str, labels: Dict[str, str], namespace: str, help_string: str, unit: str, value: float): + """ + Create a gauge type value in the provider. + + :param name: the metric name + :param labels: the metric labels + :param namespace: the metric namespace + :param help_string: the metric help string + :param unit: the metric unit + :param value: the metric value + """ + pass + + @abc.abstractmethod + def histogram(self, name: str, labels: Dict[str, str], namespace: str, help_string: str, unit: str, value: float): + """ + Create a histogram type value in the provider. + + :param name: the metric name + :param labels: the metric labels + :param namespace: the metric namespace + :param help_string: the metric help string + :param unit: the metric unit + :param value: the metric value + """ + pass + + @abc.abstractmethod + def summary(self, name: str, labels: Dict[str, str], namespace: str, help_string: str, unit: str, value: float): + """ + Create a summary type value in the provider. + + :param name: the metric name + :param labels: the metric labels + :param namespace: the metric namespace + :param help_string: the metric help string + :param unit: the metric unit + :param value: the metric value + """ + pass + + def clear(self): + """Remove any registrations.""" + pass diff --git a/src/deep/api/plugin/metric/prometheus_metrics.py b/src/deep/api/plugin/metric/prometheus_metrics.py new file mode 100644 index 0000000..3e21a9e --- /dev/null +++ b/src/deep/api/plugin/metric/prometheus_metrics.py @@ -0,0 +1,157 @@ +# Copyright (C) 2024 Intergral GmbH +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +"""Add support for prometheus metrics.""" + +import threading +from typing import Dict + +import deep.logging +from deep.api.plugin import DidNotEnable, Plugin +from deep.api.plugin.metric import MetricProcessor + +try: + from prometheus_client import Summary, Counter, REGISTRY, Histogram, Gauge +except ImportError as e: + raise DidNotEnable("opentelemetry is not installed", e) + + +class PrometheusPlugin(Plugin, MetricProcessor): + """Connect Deep to prometheus.""" + + def __init__(self, config): + """Create new plugin.""" + super().__init__("PrometheusPlugin", config) + self.__cache = {} + self.__lock = threading.Lock() + + def __check_cache(self, name, type_name, from_default): + cache_key = f'{name}_{type_name}' + if cache_key in self.__cache: + return self.__cache[cache_key] + default = from_default() + self.__cache[cache_key] = default + return default + + def counter(self, name: str, labels: Dict[str, str], namespace: str, help_string: str, unit: str, value: float): + """ + Create a counter type value in the provider. + + :param name: the metric name + :param labels: the metric labels + :param namespace: the metric namespace + :param help_string: the metric help string + :param unit: the metric unit + :param value: the metric value + """ + try: + with self.__lock: + label_keys = list(labels.keys()) + counter: Counter = self.__check_cache(name, "counter", + lambda: Counter(name=name, documentation=help_string or "", + labelnames=label_keys, + namespace=namespace, unit=unit)) + if len(labels) > 0: + counter = counter.labels(**labels) + counter.inc(value) + except Exception: + deep.logging.exception(f"Error registering metric counter {namespace}_{name}") + pass + + def gauge(self, name: str, labels: Dict[str, str], namespace: str, help_string: str, unit: str, value: float): + """ + Create a gauge type value in the provider. + + :param name: the metric name + :param labels: the metric labels + :param namespace: the metric namespace + :param help_string: the metric help string + :param unit: the metric unit + :param value: the metric value + """ + try: + with self.__lock: + label_keys = list(labels.keys()) + gauge: Gauge = self.__check_cache(name, "gauge", + lambda: Gauge(name=name, documentation=help_string or "", + labelnames=label_keys, + namespace=namespace, unit=unit)) + if len(labels) > 0: + gauge = gauge.labels(**labels) + gauge.inc(value) + except Exception: + deep.logging.exception(f"Error registering metric gauge {namespace}_{name}") + pass + + def histogram(self, name: str, labels: Dict[str, str], namespace: str, help_string: str, unit: str, value: float): + """ + Create a histogram type value in the provider. + + :param name: the metric name + :param labels: the metric labels + :param namespace: the metric namespace + :param help_string: the metric help string + :param unit: the metric unit + :param value: the metric value + """ + try: + with self.__lock: + label_keys = list(labels.keys()) + histogram: Histogram = self.__check_cache(name, "histogram", + lambda: Histogram(name=name, documentation=help_string or "", + labelnames=label_keys, + namespace=namespace, unit=unit)) + if len(labels) > 0: + histogram = histogram.labels(**labels) + histogram.observe(value) + except Exception: + deep.logging.exception(f"Error registering metric histogram {namespace}_{name}") + pass + + def summary(self, name: str, labels: Dict[str, str], namespace: str, help_string: str, unit: str, value: float): + """ + Create a summary type value in the provider. + + :param name: the metric name + :param labels: the metric labels + :param namespace: the metric namespace + :param help_string: the metric help string + :param unit: the metric unit + :param value: the metric value + """ + try: + with self.__lock: + label_keys = list(labels.keys()) + summary: Summary = self.__check_cache(name, "summary", + lambda: Summary(name=name, documentation=help_string or "", + labelnames=label_keys, + namespace=namespace, unit=unit)) + if len(labels) > 0: + summary = summary.labels(**labels) + summary.observe(value) + except Exception: + deep.logging.exception(f"Error registering metric summary {namespace}_{name}") + pass + + def shutdown(self): + """Clean up and shutdown the plugin.""" + self.clear() + + def clear(self): + """Remove any registrations.""" + with self.__lock: + for metric in self.__cache.values(): + REGISTRY.unregister(metric) + self.__cache = {} diff --git a/src/deep/api/tracepoint/tracepoint_config.py b/src/deep/api/tracepoint/tracepoint_config.py index e246bc7..10910b8 100644 --- a/src/deep/api/tracepoint/tracepoint_config.py +++ b/src/deep/api/tracepoint/tracepoint_config.py @@ -79,7 +79,7 @@ def in_window(self, ts): class LabelExpression: """A metric label expression.""" - def __init__(self, key: str, static: Optional[any], expression: Optional[str]): + def __init__(self, key: str, static: Optional[any] = None, expression: Optional[str] = None): """ Create a new label expression. @@ -106,30 +106,74 @@ def expression(self): """The label expression.""" return self.__expression + def __str__(self) -> str: + """Represent this object as a string.""" + return str(self.__dict__) + + def __repr__(self) -> str: + """Represent this object as a string.""" + return self.__str__() + + def __eq__(self, other): + """Check if other object is equals to this one.""" + if not isinstance(other, LabelExpression): + return False + return ( + self.__key == other.__key + and self.__static == other.__static + and self.__expression == other.__expression + ) + class MetricDefinition: """The definition of a metric to collect.""" - def __init__(self, name: str, labels: List[LabelExpression], type_p: str, expression: Optional[str], - namespace: Optional[str], help_p: Optional[str], unit: Optional[str]): + def __init__(self, name: str, metric_type: str, labels: List[LabelExpression] = None, + expression: Optional[str] = None, + namespace: Optional[str] = None, help_str: Optional[str] = None, unit: Optional[str] = None): """ Create a new metric definition. :param name: the metric name :param labels: the metric labels - :param type_p: the metrics type + :param metric_type: the metrics type :param expression: the metrics expression :param namespace: the metric namespace - :param help_p: the metric help into + :param help_str: the metric help into :param unit: the metric unit """ - self.__name = name - self.__labels = labels - self.__type = type_p - self.__expression = expression - self.__namespace = namespace - self.__help = help_p - self.__unit = unit + if labels is None: + labels = [] + + self.name = name + self.labels = labels + self.type = metric_type + self.expression = expression + self.namespace = namespace + self.help = help_str + self.unit = unit + + def __str__(self) -> str: + """Represent this object as a string.""" + return str(self.__dict__) + + def __repr__(self) -> str: + """Represent this object as a string.""" + return self.__str__() + + def __eq__(self, other): + """Check if other object is equals to this one.""" + if not isinstance(other, MetricDefinition): + return False + return ( + self.name == other.name + and self.labels == other.labels + and self.type == other.type + and self.expression == other.expression + and self.namespace == other.namespace + and self.help == other.help + and self.unit == other.unit + ) class TracePointConfig: diff --git a/src/deep/api/tracepoint/trigger.py b/src/deep/api/tracepoint/trigger.py index 7250f33..a01d468 100644 --- a/src/deep/api/tracepoint/trigger.py +++ b/src/deep/api/tracepoint/trigger.py @@ -524,6 +524,7 @@ def build_metric_action(tp_id: str, args: Dict[str, str], metrics: List[MetricDe :param tp_id: the tracepoint id :param args: the args + :param metrics: the tracepoint metrics :return: the location action """ if metrics is None or len(metrics) == 0: diff --git a/src/deep/config/config_service.py b/src/deep/config/config_service.py index 6e6e0fc..b53fe2b 100644 --- a/src/deep/config/config_service.py +++ b/src/deep/config/config_service.py @@ -20,6 +20,7 @@ from deep import logging from deep.api.plugin import Plugin, ResourceProvider, PLUGIN_TYPE, SnapshotDecorator, TracepointLogger +from deep.api.plugin.metric import MetricProcessor from deep.api.resource import Resource from deep.config.tracepoint_config import TracepointConfigService, ConfigUpdateListener @@ -141,6 +142,16 @@ def snapshot_decorators(self) -> Generator[SnapshotDecorator, None, None]: """Generator for snapshot decorators.""" return self.__plugin_generator(SnapshotDecorator) + @property + def metric_processors(self) -> Generator[MetricProcessor, None, None]: + """Generator for snapshot decorators.""" + return self.__plugin_generator(MetricProcessor) + + @property + def has_metric_processor(self) -> bool: + """Is there a configured metric processor.""" + return self._find_plugin(MetricProcessor) is not None + def is_app_frame(self, filename: str) -> Tuple[bool, Optional[str]]: """ Check if the current frame is a user application frame. @@ -165,7 +176,7 @@ def is_app_frame(self, filename: str) -> Tuple[bool, Optional[str]]: return False, None def _find_plugin(self, plugin_type) -> PLUGIN_TYPE: - return next(self.__plugin_generator(plugin_type)) + return next(self.__plugin_generator(plugin_type), None) def _find_plugins(self, plugin_type) -> List[PLUGIN_TYPE]: plugins = [] diff --git a/src/deep/grpc/__init__.py b/src/deep/grpc/__init__.py index f1f815c..dffdc84 100644 --- a/src/deep/grpc/__init__.py +++ b/src/deep/grpc/__init__.py @@ -26,6 +26,8 @@ from deepproto.proto.common.v1.common_pb2 import KeyValue, AnyValue, ArrayValue, KeyValueList # noinspection PyUnresolvedReferences from deepproto.proto.resource.v1.resource_pb2 import Resource +# noinspection PyUnresolvedReferences +from deepproto.proto.tracepoint.v1.tracepoint_pb2 import MetricType from .grpc_service import GRPCService # noqa: F401 from ..api.tracepoint.tracepoint_config import LabelExpression, MetricDefinition @@ -101,8 +103,8 @@ def convert_label_expressions(label_expressions) -> List[LabelExpression]: def __convert_metric_definition(metrics): - return [MetricDefinition(m.name, convert_label_expressions(m.labelExpressions), m.type, m.expression, m.namespace, - m.help, m.unit) for m in metrics] + return [MetricDefinition(m.name, MetricType.Name(metrics[0].type), convert_label_expressions(m.labelExpressions), + m.expression, m.namespace, m.help, m.unit) for m in metrics] def convert_response(response) -> List[Trigger]: diff --git a/src/deep/logging/__init__.py b/src/deep/logging/__init__.py index 69f0d6b..3304150 100644 --- a/src/deep/logging/__init__.py +++ b/src/deep/logging/__init__.py @@ -76,11 +76,15 @@ def exception(msg, *args, exc_info=True, **kwargs): logging.getLogger("deep").exception(msg, *args, exc_info=exc_info, **kwargs) -def init(cfg): +def init(cfg=None): """ Configure the deep log provider. :param cfg: the config for deep. """ - log_conf = cfg.LOGGING_CONF or "%s/logging.conf" % os.path.dirname(os.path.realpath(__file__)) + log_conf = "%s/logging.conf" % os.path.dirname(os.path.realpath(__file__)) + + if cfg is not None and cfg.LOGGING_CONF: + log_conf = cfg.LOGGING_CONF + logging.config.fileConfig(fname=log_conf, disable_existing_loggers=False) diff --git a/src/deep/processor/context/action_context.py b/src/deep/processor/context/action_context.py index 33b4f03..64c3b0d 100644 --- a/src/deep/processor/context/action_context.py +++ b/src/deep/processor/context/action_context.py @@ -111,19 +111,12 @@ def can_trigger(self) -> bool: """ if not self.location_action.can_trigger(self.tigger_context.ts): return False - if self.location_action.condition is None: + if self.location_action.condition is None or len(self.location_action.condition.strip()) == 0: return True result = self.tigger_context.evaluate_expression(self.location_action.condition) return str2bool(str(result)) -class MetricActionContext(ActionContext): - """Action for metrics.""" - - def _process_action(self): - pass - - class SpanActionContext(ActionContext): """Action for spans.""" diff --git a/src/deep/processor/context/metric_action.py b/src/deep/processor/context/metric_action.py new file mode 100644 index 0000000..745779e --- /dev/null +++ b/src/deep/processor/context/metric_action.py @@ -0,0 +1,77 @@ +# Copyright (C) 2024 Intergral GmbH +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +"""Provide metric actions.""" + +from typing import List, Tuple, Dict + +import deep.logging +from deep.api.tracepoint.tracepoint_config import MetricDefinition +from deep.processor.context.action_context import ActionContext + + +class MetricActionContext(ActionContext): + """Action for metrics.""" + + def can_trigger(self) -> bool: + """ + Check if the action can trigger. + + If we do not have a metric processor enabled, then skip this action. + :return: True, if the trigger can be triggered. + """ + if self.__has_metric_processor(): + return super().can_trigger() + return False + + def _process_action(self): + metrics = self._metrics() + for metric in metrics: + labels, value = self._process_metric(metric) + for processor in self.tigger_context.config.metric_processors: + getattr(processor, self._convert_type(metric.type))(metric.name, labels, metric.namespace or "deep", + metric.help, metric.unit, value) + + def __has_metric_processor(self): + return self.tigger_context.config.has_metric_processor + + def _metrics(self) -> List[MetricDefinition]: + return self.location_action.config['metrics'] + + def _convert_type(self, metric_type): + return metric_type.lower() + + def _process_metric(self, metric: MetricDefinition) -> Tuple[Dict[str, str], float]: + metric_value = 1 + if metric.expression: + try: + metric_value = float(self.tigger_context.evaluate_expression(metric.expression)) + except Exception: + deep.logging.exception("Cannot process metric expression %s", metric.expression) + + labels = {} + if len(metric.labels) > 0: + for label in metric.labels: + key = label.key + if label.expression: + try: + value = str(self.tigger_context.evaluate_expression(label.expression)) + except Exception: + deep.logging.exception("Cannot process metric label expression %s: %s", key, label.expression) + value = 'expression failed' + else: + value = label.static + labels[key] = value + return labels, metric_value diff --git a/src/deep/processor/context/trigger_context.py b/src/deep/processor/context/trigger_context.py index d882228..2118c32 100644 --- a/src/deep/processor/context/trigger_context.py +++ b/src/deep/processor/context/trigger_context.py @@ -24,9 +24,10 @@ from deep.api.tracepoint import Variable from deep.api.tracepoint.trigger import LocationAction from deep.config import ConfigService -from deep.processor.context.action_context import MetricActionContext, SpanActionContext, NoActionContext, ActionContext +from deep.processor.context.action_context import SpanActionContext, NoActionContext, ActionContext from deep.processor.context.action_results import ActionResult, ActionCallback from deep.processor.context.log_action import LogActionContext +from deep.processor.context.metric_action import MetricActionContext from deep.processor.context.snapshot_action import SnapshotActionContext from deep.processor.frame_collector import FrameCollector from deep.processor.variable_set_processor import VariableCacheProvider @@ -108,7 +109,7 @@ def frame(self): return self.__frame @property - def config(self): + def config(self) -> ConfigService: """The config service.""" return self.__config diff --git a/src/deep/processor/variable_processor.py b/src/deep/processor/variable_processor.py index c632090..42cad07 100644 --- a/src/deep/processor/variable_processor.py +++ b/src/deep/processor/variable_processor.py @@ -28,7 +28,6 @@ from deep.api.tracepoint import VariableId, Variable from .bfs import Node, ParentNode, NodeValue - NO_CHILD_TYPES = [ 'str', 'int', @@ -195,8 +194,12 @@ def variable_to_string(variable_type, var_value): # large, and quite pointless, instead we just get the size of the collection return 'Size: %s' % len(var_value) else: - # everything else just gets a string value - return str(var_value) + try: + # everything else just gets a string value + return str(var_value) + except Exception: + # it is possible for str to fail if there is a custom __str__ function + return f'{type(var_value)}@{id(var_value)}' def process_variable(var_collector: Collector, node: NodeValue) -> VariableResponse: diff --git a/tests/it_tests/test_it_basic.py b/tests/it_tests/test_it_basic.py index 91e1e6a..f3094b6 100644 --- a/tests/it_tests/test_it_basic.py +++ b/tests/it_tests/test_it_basic.py @@ -17,12 +17,16 @@ import logging import unittest +import prometheus_client + import deep import it_tests from deep.api.tracepoint.constants import LOG_MSG, SNAPSHOT, NO_COLLECT from it_tests.it_utils import start_server, MockServer from it_tests.test_target import BPTargetTest from test_utils import find_var_in_snap_by_name, find_var_in_snap_by_path +# noinspection PyUnresolvedReferences +from deepproto.proto.tracepoint.v1.tracepoint_pb2 import Metric, MetricType class BasicITTest(unittest.TestCase): @@ -121,3 +125,32 @@ def test_log_only_action(self): self.assertIsNone(snapshot) self.assertIn("[deep] test log name", logs.output[0]) _deep.shutdown() + + def test_metric_only_action(self): + """ + For some reason the log message doesn't appear in the output, but it is logged. + + This can be verified by changing the assertion, or looking at the tracepoint handler output. + """ + server: MockServer + + with start_server() as server: + server.add_tp("test_target.py", 40, {SNAPSHOT: NO_COLLECT}, [], + [Metric(name="simple_test", type=MetricType.COUNTER)]) + _deep = deep.start(server.config()) + server.await_poll() + + test = BPTargetTest("name", 123) + _ = test.name + # we do not want a snapshot, but we have to await to see if one is sent. So we just wait 5 seconds, + # as it should not take this long for a snapshot to be sent if it was triggered. + snapshot = server.await_snapshot(timeout=5) + + self.assertIsNone(snapshot) + + self.assertIsNotNone(prometheus_client.REGISTRY._names_to_collectors['deep_simple_test_total']) + + _deep.shutdown() + + with self.assertRaises(KeyError): + _ = prometheus_client.REGISTRY._names_to_collectors['deep_simple_test_total'] diff --git a/tests/unit_tests/api/plugin/metrics/test_prometheus_metrics.py b/tests/unit_tests/api/plugin/metrics/test_prometheus_metrics.py new file mode 100644 index 0000000..8234ffc --- /dev/null +++ b/tests/unit_tests/api/plugin/metrics/test_prometheus_metrics.py @@ -0,0 +1,38 @@ +# Copyright (C) 2024 Intergral GmbH +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +import unittest + +import deep.logging +from deep.api.plugin.metric.prometheus_metrics import PrometheusPlugin + + +class TestPrometheusMetrics(unittest.TestCase): + + def setUp(self): + deep.logging.init() + self.plugin = PrometheusPlugin(None) + + def tearDown(self): + self.plugin.clear() + + def test_counter(self): + self.plugin.counter("test", {}, "deep", "", "", 123) + + def test_counter_with_labels(self): + self.plugin.counter("test_other", {'value': 'name'}, "deep", "", "", 123) + + def test_duplicate_registration(self): + self.plugin.counter("test_other", {}, "deep", "", "", 123) + self.plugin.counter("test_other", {'value': 'name'}, "deep", "", "", 123) diff --git a/tests/unit_tests/api/plugin/test_plugin.py b/tests/unit_tests/api/plugin/test_plugin.py index f5f118b..32879c8 100644 --- a/tests/unit_tests/api/plugin/test_plugin.py +++ b/tests/unit_tests/api/plugin/test_plugin.py @@ -34,13 +34,13 @@ def setUp(self): def test_load_plugins(self): plugins = load_plugins(None) self.assertIsNotNone(plugins) - self.assertEqual(2, len(plugins)) + self.assertEqual(3, len(plugins)) def test_handle_bad_plugin(self): plugins = load_plugins(None, [BadPlugin.__qualname__]) - self.assertEqual(2, len(plugins)) + self.assertEqual(3, len(plugins)) plugins = load_plugins(None, [BadPlugin.__module__ + '.' + BadPlugin.__name__]) - self.assertEqual(2, len(plugins)) + self.assertEqual(3, len(plugins)) diff --git a/tests/unit_tests/processor/context/test_metric_action.py b/tests/unit_tests/processor/context/test_metric_action.py new file mode 100644 index 0000000..04d7871 --- /dev/null +++ b/tests/unit_tests/processor/context/test_metric_action.py @@ -0,0 +1,161 @@ +# Copyright (C) 2024 Intergral GmbH +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +import unittest + +import mockito + +import deep.logging +from deep.api.tracepoint.tracepoint_config import MetricDefinition, LabelExpression +from deep.processor.context.metric_action import MetricActionContext + + +class TestMetricAction(unittest.TestCase): + + def setUp(self): + deep.logging.init() + + def test_metric_action(self): + parent = mockito.mock() + parent.config = mockito.mock() + parent.config.has_metric_processor = True + metric_processor = mockito.mock() + mockito.when(metric_processor).counter("simple_test", {}, 'deep', None, None, 1).thenReturn() + parent.config.metric_processors = (m for m in [metric_processor]) + action = mockito.mock() + action.can_trigger = lambda x: True + action.condition = None + action.config = { + 'metrics': [MetricDefinition(name="simple_test", metric_type="counter")] + } + context = MetricActionContext(parent, action) + context.process() + self.assertTrue(context.can_trigger()) + + mockito.verify(metric_processor, mockito.times(1)).counter("simple_test", {}, 'deep', None, None, 1) + + def test_metric_action_with_expression(self): + parent = mockito.mock() + mockito.when(parent).evaluate_expression("len([1,2,3])").thenReturn(3) + parent.config = mockito.mock() + parent.config.has_metric_processor = True + metric_processor = mockito.mock() + mockito.when(metric_processor).counter("simple_test", {}, 'deep', None, None, 3).thenReturn() + parent.config.metric_processors = (m for m in [metric_processor]) + action = mockito.mock() + action.can_trigger = lambda x: True + action.condition = None + action.config = { + 'metrics': [MetricDefinition(name="simple_test", metric_type="counter", expression="len([1,2,3])")] + } + context = MetricActionContext(parent, action) + context.process() + self.assertTrue(context.can_trigger()) + + mockito.verify(metric_processor, mockito.times(1)).counter("simple_test", {}, 'deep', None, None, 3) + + def test_metric_action_with_bad_expression(self): + parent = mockito.mock() + mockito.when(parent).evaluate_expression("len([1,2,3])").thenReturn(None) + parent.config = mockito.mock() + parent.config.has_metric_processor = True + metric_processor = mockito.mock() + mockito.when(metric_processor).counter("simple_test", {}, 'deep', None, None, 3).thenReturn() + parent.config.metric_processors = (m for m in [metric_processor]) + action = mockito.mock() + action.can_trigger = lambda x: True + action.condition = None + action.config = { + 'metrics': [MetricDefinition(name="simple_test", metric_type="counter", expression="len([1,2,3])")] + } + context = MetricActionContext(parent, action) + context.process() + self.assertTrue(context.can_trigger()) + + mockito.verify(metric_processor, mockito.times(1)).counter("simple_test", {}, 'deep', None, None, 1) + + def test_metric_action_with_label_expression(self): + parent = mockito.mock() + mockito.when(parent).evaluate_expression("len([1,2,3])").thenReturn("None") + parent.config = mockito.mock() + parent.config.has_metric_processor = True + metric_processor = mockito.mock() + mockito.when(metric_processor).counter("simple_test", {}, 'deep', None, None, 3).thenReturn() + parent.config.metric_processors = (m for m in [metric_processor]) + action = mockito.mock() + action.can_trigger = lambda x: True + action.condition = None + action.config = { + 'metrics': [MetricDefinition(name="simple_test", metric_type="counter", + labels=[LabelExpression(key="test", expression="len([1,2,3])")])] + } + context = MetricActionContext(parent, action) + context.process() + self.assertTrue(context.can_trigger()) + + mockito.verify(metric_processor, mockito.times(1)).counter("simple_test", {"test": "None"}, 'deep', None, None, + 1) + + def test_metric_action_with_label_bad_expression(self): + parent = mockito.mock() + mockito.when(parent).evaluate_expression("len([1,2,3])").thenRaise(Exception("test: bad expression")) + parent.config = mockito.mock() + parent.config.has_metric_processor = True + metric_processor = mockito.mock() + mockito.when(metric_processor).counter("simple_test", {}, 'deep', None, None, 3).thenReturn() + parent.config.metric_processors = (m for m in [metric_processor]) + action = mockito.mock() + action.can_trigger = lambda x: True + action.condition = None + action.config = { + 'metrics': [MetricDefinition(name="simple_test", metric_type="counter", + labels=[LabelExpression(key="test", expression="len([1,2,3])")])] + } + context = MetricActionContext(parent, action) + context.process() + self.assertTrue(context.can_trigger()) + + mockito.verify(metric_processor, mockito.times(1)).counter("simple_test", {'test': 'expression failed'}, 'deep', + None, None, 1) + + def test_metric_action_with_label_static(self): + parent = mockito.mock() + mockito.when(parent).evaluate_expression("len([1,2,3])").thenRaise(Exception("test: bad expression")) + parent.config = mockito.mock() + parent.config.has_metric_processor = True + metric_processor = mockito.mock() + mockito.when(metric_processor).counter("simple_test", {}, 'deep', None, None, 3).thenReturn() + parent.config.metric_processors = (m for m in [metric_processor]) + action = mockito.mock() + action.can_trigger = lambda x: True + action.condition = None + action.config = { + 'metrics': [MetricDefinition(name="simple_test", metric_type="counter", + labels=[LabelExpression(key="test", static="len([1,2,3])")])] + } + context = MetricActionContext(parent, action) + context.process() + self.assertTrue(context.can_trigger()) + + mockito.verify(metric_processor, mockito.times(1)).counter("simple_test", {'test': 'len([1,2,3])'}, 'deep', + None, None, 1) + + def test_no_processors(self): + parent = mockito.mock() + parent.config = mockito.mock() + parent.config.has_metric_processor = False + + action = mockito.mock() + context = MetricActionContext(parent, action) + self.assertFalse(context.can_trigger()) diff --git a/tests/unit_tests/processor/test_trigger_handler.py b/tests/unit_tests/processor/test_trigger_handler.py index 991c16e..9bd8c77 100644 --- a/tests/unit_tests/processor/test_trigger_handler.py +++ b/tests/unit_tests/processor/test_trigger_handler.py @@ -31,11 +31,15 @@ from threading import Thread from typing import List +import mockito + from deep import logging from deep.api.plugin import TracepointLogger +from deep.api.plugin.metric import MetricProcessor from deep.api.resource import Resource from deep.api.tracepoint.constants import LOG_MSG, WATCHES from deep.api.tracepoint.eventsnapshot import EventSnapshot +from deep.api.tracepoint.tracepoint_config import MetricDefinition from deep.api.tracepoint.trigger import Location, LocationAction, LineLocation, Trigger from deep.config import ConfigService @@ -198,3 +202,29 @@ def test_snapshot_action_with_condition(self): self.assertEqual(0, len(logged)) pushed = push.pushed self.assertEqual(0, len(pushed)) + + def test_metric_action(self): + capture = TraceCallCapture() + config = MockConfigService({}) + mock_plugin = mockito.mock(spec=MetricProcessor) + mockito.when(mock_plugin).counter("simple_test", {}, 'deep', None, None, 1).thenReturn() + config.plugins = [mock_plugin] + push = MockPushService(None, None) + handler = TriggerHandler(config, push) + + location = LineLocation('test_target.py', 27, Location.Position.START) + handler.new_config([Trigger(location, [ + LocationAction("tp_id", "", + {'metrics': [MetricDefinition(name="simple_test", metric_type="counter")]}, + LocationAction.ActionType.Metric)])]) + + self.call_and_capture(location, some_test_function, ['input'], capture) + + handler.trace_call(capture.captured_frame, capture.captured_event, capture.captured_args) + + logged = config.logger.logged + self.assertEqual(0, len(logged)) + pushed = push.pushed + self.assertEqual(0, len(pushed)) + + mockito.verify(mock_plugin, mockito.times(1)).counter("simple_test", {}, 'deep', None, None, 1) diff --git a/tests/unit_tests/tracepoint/test_trigger.py b/tests/unit_tests/tracepoint/test_trigger.py index 5576bbc..8f4d030 100644 --- a/tests/unit_tests/tracepoint/test_trigger.py +++ b/tests/unit_tests/tracepoint/test_trigger.py @@ -1,4 +1,17 @@ -# Copyright (C) 2023 Intergral GmbH +# Copyright (C) 2024 Intergral GmbH +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU Affero General Public License as published by @@ -17,6 +30,7 @@ from parameterized import parameterized +from deep.api.tracepoint.tracepoint_config import MetricDefinition from deep.api.tracepoint.trigger import build_trigger, LineLocation, LocationAction, Trigger, Location @@ -58,6 +72,23 @@ class Test(TestCase): 'fire_period': '1000', 'log_msg': 'some_log', }, LocationAction.ActionType.Snapshot), + ])], + # should create metric action + ["some.file", 123, {}, [], [MetricDefinition(name="simple_test", metric_type="counter")], + Trigger(LineLocation("some.file", 123, Location.Position.START), [ + LocationAction("tp-id", None, { + 'watches': [], + 'frame_type': 'single_frame', + 'stack_type': 'stack', + 'fire_count': '1', + 'fire_period': '1000', + 'log_msg': None, + }, LocationAction.ActionType.Snapshot), + LocationAction("tp-id", None, { + 'metrics': [MetricDefinition(name="simple_test", metric_type="counter")], + 'fire_count': '1', + 'fire_period': '1000', + }, LocationAction.ActionType.Metric), ])] ]) def test_build_triggers(self, file, line, args, watches, metrics, expected):