Add metrics API 28/4428/4
authorLott, Christopher (cl778h) <cl778h@att.com>
Thu, 23 Jul 2020 10:34:58 +0000 (06:34 -0400)
committerLott, Christopher (cl778h) <cl778h@att.com>
Sat, 25 Jul 2020 21:52:33 +0000 (17:52 -0400)
Defines MetricsData, MetricsReport and MetricsManager classes
so client xApps can generate and send reports via RMR.

Issue-ID: RIC-381
Signed-off-by: Lott, Christopher (cl778h) <cl778h@att.com>
Change-Id: I46890db2e9fedd007412cb480f592d059687ccf8

.gitignore
docs/release-notes.rst
ricxappframe/metric/__init__.py [new file with mode: 0644]
ricxappframe/metric/exceptions.py [new file with mode: 0644]
ricxappframe/metric/metric-schema.json [new file with mode: 0644]
ricxappframe/metric/metric.py [new file with mode: 0644]
setup.py
tests/fixtures/test_local.rt
tests/test_alarm.py
tests/test_metric.py [new file with mode: 0644]

index c86c4b7..1a9a867 100644 (file)
@@ -114,3 +114,6 @@ coverage-reports
 .project
 .pydevproject
 .settings/
+
+# VS Code
+.vscode/
index c122621..1b82c2d 100644 (file)
@@ -11,10 +11,16 @@ The format is based on `Keep a Changelog <http://keepachangelog.com/>`__
 and this project adheres to `Semantic Versioning <http://semver.org/>`__.
 
 
+[1.5.0] - 2020-07-10
+--------------------
+* Add Metrics API (`RIC-381 <https://jira.o-ran-sc.org/browse/RIC-381>`_)
+
+
 [1.4.0] - 2020-07-06
 --------------------
 * Revise Alarm manager to send via RMR wormhole (`RIC-529 <https://jira.o-ran-sc.org/browse/RIC-529>`_)
 
+
 [1.3.0] - 2020-06-24
 --------------------
 * Add configuration-change API (`RIC-425 <https://jira.o-ran-sc.org/browse/RIC-425>`_)
diff --git a/ricxappframe/metric/__init__.py b/ricxappframe/metric/__init__.py
new file mode 100644 (file)
index 0000000..82aac02
--- /dev/null
@@ -0,0 +1,16 @@
+# ==================================================================================
+#       Copyright (c) 2020 AT&T Intellectual Property.
+#       Copyright (c) 2020 Nokia
+#
+#   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/ricxappframe/metric/exceptions.py b/ricxappframe/metric/exceptions.py
new file mode 100644 (file)
index 0000000..d9b4cb2
--- /dev/null
@@ -0,0 +1,23 @@
+# ==================================================================================
+#       Copyright (c) 2020 AT&T Intellectual Property.
+#       Copyright (c) 2020 Nokia
+#
+#   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.
+# ==================================================================================
+"""
+Custom Exceptions
+"""
+
+
+class EmptyReport(BaseException):
+    """No metric data items are present"""
diff --git a/ricxappframe/metric/metric-schema.json b/ricxappframe/metric/metric-schema.json
new file mode 100644 (file)
index 0000000..7c4a878
--- /dev/null
@@ -0,0 +1,51 @@
+{
+  "$schema": "http://json-schema.org/draft-04/schema#",
+  "type": "object",
+  "properties": {
+    "reporter": {
+      "description": "the system reporting the metric",
+      "default":  "the RMR source string (host:port)",
+      "type": "string"
+    },
+    "generator": {
+      "description": "the system which generated the metric",
+      "default": "reporter is assumed to be the generator if this is omitted",
+      "type": "string"
+    },
+    "timestamp": {
+      "description": "milliseconds past the UNIX epoch; e.g. 1594298319000 == 2020/07/09 12:38:39.000Z",
+      "default":  "the message arrival time",
+      "type": "integer"
+    },
+    "data": {
+      "description": "one or more measurements",
+      "type": "array",
+      "items": [
+        {
+          "type": "object",
+          "properties": {
+            "id": {
+              "description": "measurement name",
+              "type": "string"
+            },
+            "type": { 
+              "description": "future: measurement type such as counter, value, or similar",
+              "type": "string"
+            },
+            "value": {
+              "description": "actual value; treated as a double when forwarded",
+              "type": "number"
+            }
+          },
+          "required": [
+            "id",
+            "value"
+          ]
+        }
+      ]
+    }
+  },
+  "required": [
+    "data"
+  ]
+}
diff --git a/ricxappframe/metric/metric.py b/ricxappframe/metric/metric.py
new file mode 100644 (file)
index 0000000..14186dd
--- /dev/null
@@ -0,0 +1,221 @@
+# ==================================================================================
+#       Copyright (c) 2020 AT&T Intellectual Property.
+#       Copyright (c) 2020 Nokia
+#
+#   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.
+# ==================================================================================
+"""
+Provides classes and methods to define and send metrics as RMR messages to a
+central collector. Message destination(s) are controlled by the RMR routing table.
+Message contents must comply with the JSON schema in file metric-schema.json.
+"""
+
+from ctypes import c_void_p
+import json
+import time
+from mdclogpy import Logger
+from ricxappframe.rmr import rmr
+from ricxappframe.metric.exceptions import EmptyReport
+
+##############
+# PRIVATE API
+##############
+
+mdc_logger = Logger(name=__name__)
+RETRIES = 4
+
+##############
+# PUBLIC API
+##############
+
+# constants
+RIC_METRICS = 120  # message type
+
+# Publish dict keys as constants for convenience of client code.
+KEY_REPORTER = "reporter"
+KEY_GENERATOR = "generator"
+KEY_TIMESTAMP = "timestamp"
+KEY_DATA = "data"
+KEY_DATA_ID = "id"
+KEY_DATA_TYPE = "type"
+KEY_DATA_VALUE = "value"
+
+
+class MetricData(dict):
+    """
+    A single measurement with ID, value and (optionally) type.
+    """
+    def __init__(self,
+                 id: str,
+                 value: str,
+                 type: str = None):
+        """
+        Creates a data item with the specified members.
+
+        Parameters
+        ----------
+        id: str (required)
+            Metric ID
+
+        value: str (required)
+            Metric value; e.g., 1.
+
+        type: str (optional)
+            Metric type; e.g., "counter".
+        """
+        dict.__init__(self)
+        self[KEY_DATA_ID] = id
+        self[KEY_DATA_VALUE] = value
+        self[KEY_DATA_TYPE] = type
+
+
+class MetricsReport(dict):
+    """
+    A list of metric data items with identifying information.
+    At init sets the timestamp to the current system time in
+    milliseconds since the Epoch.
+
+    Parameters
+    ----------
+    reporter: str (optional)
+        The system that reports the data
+
+    generator: str (optional)
+        The generator that reports the data
+
+    items: List of MetricData (optional)
+        The data items for the report
+    """
+    def __init__(self,
+                 reporter: str = None,
+                 generator: str = None,
+                 items: list = None):
+        """
+        Creates an object with the specified details and items.
+        """
+        dict.__init__(self)
+        self[KEY_REPORTER] = reporter
+        self[KEY_GENERATOR] = generator
+        self[KEY_TIMESTAMP] = int(round(time.time() * 1000))
+        self[KEY_DATA] = [] if items is None else items
+
+    def add_metric(self,
+                   data: MetricData):
+        """
+        Convenience method that adds a data item to the report.
+
+        Parameters
+        ----------
+        data: MetricData
+            A measurement to add to the report
+        """
+        self[KEY_DATA].append(data)
+
+
+class MetricsManager:
+    """
+    Provides an API for an Xapp to build and send measurement reports
+    by sending messages via RMR routing to a metrics adapter/collector.
+
+    Parameters
+    ----------
+    vctx: ctypes c_void_p (required)
+        Pointer to RMR context obtained by initializing RMR.
+        The context is used to allocate space and send messages.
+
+    reporter: str (optional)
+        The source of the measurement; e.g., a temperature probe
+
+    generator: str (optional)
+        The system that collected and sent the measurement; e.g., an environment monitor.
+    """
+    def __init__(self,
+                 vctx: c_void_p,
+                 reporter: str = None,
+                 generator: str = None):
+        """
+        Creates a metrics manager.
+        """
+        self.vctx = vctx
+        self.reporter = reporter
+        self.generator = generator
+
+    def create_report(self,
+                      items: list = None):
+        """
+        Creates a MetricsReport object with the specified metrics data items.
+
+        Parameters
+        ----------
+        items: list (optional)
+            List of MetricData items
+
+        Returns
+        -------
+        MetricsReport
+        """
+        return MetricsReport(self.reporter, self.generator, items)
+
+    def send_report(self, msg: MetricsReport):
+        """
+        Serializes the MetricsReport dict to JSON and sends the result via RMR.
+        Raises an exception if the report has no MetricsData items.
+
+        Parameters
+        ----------
+        msg: MetricsReport (required)
+            Dictionary with measurement data to encode and send
+
+        Returns
+        -------
+        bool
+            True if the send succeeded (possibly with retries), False otherwise
+        """
+        if KEY_DATA not in msg or len(msg[KEY_DATA]) == 0:
+            raise EmptyReport
+        payload = json.dumps(msg).encode()
+        mdc_logger.debug("send_report: payload is {}".format(payload))
+        sbuf = rmr.rmr_alloc_msg(vctx=self.vctx, size=len(payload), payload=payload,
+                                 mtype=RIC_METRICS, gen_transaction_id=True)
+
+        for _ in range(0, RETRIES):
+            sbuf = rmr.rmr_send_msg(self.vctx, sbuf)
+            post_send_summary = rmr.message_summary(sbuf)
+            mdc_logger.debug("send_report: try {0} result is {1}".format(_, post_send_summary[rmr.RMR_MS_MSG_STATE]))
+            # stop trying if RMR does not indicate retry
+            if post_send_summary[rmr.RMR_MS_MSG_STATE] != rmr.RMR_ERR_RETRY:
+                break
+
+        rmr.rmr_free_msg(sbuf)
+        if post_send_summary[rmr.RMR_MS_MSG_STATE] != rmr.RMR_OK:
+            mdc_logger.warning("send_report: failed after {} retries".format(RETRIES))
+            return False
+
+        return True
+
+    def send_metrics(self, items: list):
+        """
+        Convenience method that creates a MetricsReport object with the specified
+        metrics data items and sends it to the metrics adapter/collector.
+
+        Parameters
+        ----------
+        items: list (required)
+            List of MetricData items
+
+        Returns
+        -------
+        bool
+            True if the send succeeded (possibly with retries), False otherwise
+        """
+        return self.send_report(self.create_report(items))
index d20eda6..dbd455e 100644 (file)
--- a/setup.py
+++ b/setup.py
@@ -32,10 +32,10 @@ def _long_descr():
 
 setup(
     name="ricxappframe",
-    version="1.4.0",
+    version="1.5.0",
     packages=find_packages(exclude=["tests.*", "tests"]),
     author="O-RAN Software Community",
-    description="Xapp and RMR framework for python",
+    description="Xapp and RMR framework for Python",
     url="https://gerrit.o-ran-sc.org/r/admin/repos/ric-plt/xapp-frame-py",
     install_requires=["inotify_simple", "msgpack", "mdclogpy", "ricsdl>=2.0.3,<3.0.0"],
     classifiers=[
index f9d14ce..b821a51 100644 (file)
@@ -1,12 +1,13 @@
 # do NOT use localhost, seems unresolved on jenkins VMs
-# first 3 lines (port 4564) are used for xapp frame tests
-# next 4 lines (port 3563-3564) are used in the rmr submodule
+# first 4 lines (ports 4564, 4569) are used for xapp frame tests
+# last 4 lines (port 3563, 3564) are used in the rmr submodule
 newrt|start
-mse| 60000 | -1 | 127.0.0.1:4564
-mse| 60001 | -1 | 127.0.0.1:4564
-mse| 100 | -1 | 127.0.0.1:4564
-mse| 0 | -1 | 127.0.0.1:3563
+mse| 60000 |  -1 | 127.0.0.1:4564
+mse| 60001 |  -1 | 127.0.0.1:4564
+mse|   100 |  -1 | 127.0.0.1:4564
+mse|   120 |  -1 | 127.0.0.1:4569
+mse|     0 |  -1 | 127.0.0.1:3563
 mse| 46656 | 777 | 127.0.0.1:3563
-mse| 1 | -1 | 127.0.0.1:3564
-mse| 2 | -1 | 127.0.0.1:3564
+mse|     1 |  -1 | 127.0.0.1:3564
+mse|     2 |  -1 | 127.0.0.1:3564
 newrt|end
index 0cde0ce..df8b2d4 100644 (file)
@@ -47,6 +47,7 @@ def teardown_module():
     test alarm module teardown
     """
     rmr.rmr_close(MRC_SEND)
+    rmr.rmr_close(MRC_RCV)
 
 
 def test_alarm_set_get(monkeypatch):
diff --git a/tests/test_metric.py b/tests/test_metric.py
new file mode 100644 (file)
index 0000000..a40c978
--- /dev/null
@@ -0,0 +1,115 @@
+# =================================================================================2
+#       Copyright (c) 2020 AT&T Intellectual Property.
+#       Copyright (c) 2020 Nokia
+#
+#   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.
+# ==================================================================================
+import json
+import pytest
+import time
+from mdclogpy import Logger
+from ricxappframe.metric import metric
+from ricxappframe.metric.exceptions import EmptyReport
+from ricxappframe.metric.metric import MetricData, MetricsReport, MetricsManager
+from ricxappframe.rmr import rmr
+
+mdc_logger = Logger(name=__name__)
+MRC_SEND = None
+MRC_RCV = None
+SIZE = 256
+
+
+def setup_module():
+    """
+    test metric module setup
+    """
+    global MRC_SEND
+    MRC_SEND = rmr.rmr_init(b"4568", rmr.RMR_MAX_RCV_BYTES, 0x00)
+    while rmr.rmr_ready(MRC_SEND) == 0:
+        time.sleep(1)
+
+    global MRC_RCV
+    MRC_RCV = rmr.rmr_init(b"4569", rmr.RMR_MAX_RCV_BYTES, 0x00)
+    while rmr.rmr_ready(MRC_RCV) == 0:
+        time.sleep(1)
+
+    # let all the RMR output appear
+    time.sleep(2)
+    mdc_logger.debug("RMR instances initialized")
+
+
+def teardown_module():
+    """
+    test metric module teardown
+    """
+    mdc_logger.debug("Closing RMR instances")
+    rmr.rmr_close(MRC_SEND)
+    rmr.rmr_close(MRC_RCV)
+
+
+def test_metric_set_get(monkeypatch):
+    mdc_logger.debug("Testing set functions")
+    md = MetricData("id", "value", "type")
+    assert md[metric.KEY_DATA_ID] == "id"
+    assert md[metric.KEY_DATA_VALUE] == "value"
+    assert md[metric.KEY_DATA_TYPE] == "type"
+
+    mr = MetricsReport("reporter", "generator", [md])
+    assert mr[metric.KEY_REPORTER] == "reporter"
+    assert mr[metric.KEY_GENERATOR] == "generator"
+    assert len(mr[metric.KEY_DATA]) == 1
+    assert mr[metric.KEY_DATA][0] == md
+
+    mgr = metric.MetricsManager(MRC_SEND, "reporter", "generator")
+    assert mgr is not None
+    assert mgr.reporter == "reporter"
+    assert mgr.generator == "generator"
+
+    mr = MetricsReport("empty", "empty", [])
+    with pytest.raises(EmptyReport):
+        mgr.send_report(mr)
+
+
+def _receive_metric_rpt(rptr: str):
+    """
+    delays briefly, receives a message, checks the message type and reporter
+    """
+    time.sleep(0.5)
+    sbuf_rcv = rmr.rmr_alloc_msg(MRC_RCV, SIZE)
+    sbuf_rcv = rmr.rmr_torcv_msg(MRC_RCV, sbuf_rcv, 2000)
+    rcv_summary = rmr.message_summary(sbuf_rcv)
+    mdc_logger.debug("Receive result is {}".format(rcv_summary[rmr.RMR_MS_MSG_STATE]))
+    assert rcv_summary[rmr.RMR_MS_MSG_STATE] == rmr.RMR_OK
+    assert rcv_summary[rmr.RMR_MS_MSG_TYPE] == metric.RIC_METRICS
+    # parse JSON
+    data = json.loads(rcv_summary[rmr.RMR_MS_PAYLOAD].decode())
+    mdc_logger.debug("Received reporter {}".format(data[metric.KEY_REPORTER]))
+    assert data[metric.KEY_REPORTER] == rptr
+
+
+def test_metrics_manager():
+    """
+    test send functions and ensure a message arrives
+    """
+    mdc_logger.debug("Testing metrics-send functions")
+    rptr = "metr"
+
+    mgr = MetricsManager(MRC_SEND, rptr, "generator")
+    assert mgr is not None
+
+    md = MetricData("id", "value", "type")
+    assert md is not None
+
+    success = mgr.send_metrics([md])
+    assert success
+    _receive_metric_rpt(rptr)