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):