From af8b53fd50d9efeb68330ea0016e037caf2f4636 Mon Sep 17 00:00:00 2001 From: "Lott, Christopher (cl778h)" Date: Thu, 23 Jul 2020 06:34:58 -0400 Subject: [PATCH] Add metrics API 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) Change-Id: I46890db2e9fedd007412cb480f592d059687ccf8 --- .gitignore | 3 + docs/release-notes.rst | 6 + ricxappframe/metric/__init__.py | 16 +++ ricxappframe/metric/exceptions.py | 23 ++++ ricxappframe/metric/metric-schema.json | 51 ++++++++ ricxappframe/metric/metric.py | 221 +++++++++++++++++++++++++++++++++ setup.py | 4 +- tests/fixtures/test_local.rt | 17 +-- tests/test_alarm.py | 1 + tests/test_metric.py | 115 +++++++++++++++++ 10 files changed, 447 insertions(+), 10 deletions(-) create mode 100644 ricxappframe/metric/__init__.py create mode 100644 ricxappframe/metric/exceptions.py create mode 100644 ricxappframe/metric/metric-schema.json create mode 100644 ricxappframe/metric/metric.py create mode 100644 tests/test_metric.py diff --git a/.gitignore b/.gitignore index c86c4b7..1a9a867 100644 --- a/.gitignore +++ b/.gitignore @@ -114,3 +114,6 @@ coverage-reports .project .pydevproject .settings/ + +# VS Code +.vscode/ diff --git a/docs/release-notes.rst b/docs/release-notes.rst index c122621..1b82c2d 100644 --- a/docs/release-notes.rst +++ b/docs/release-notes.rst @@ -11,10 +11,16 @@ The format is based on `Keep a Changelog `__ and this project adheres to `Semantic Versioning `__. +[1.5.0] - 2020-07-10 +-------------------- +* Add Metrics API (`RIC-381 `_) + + [1.4.0] - 2020-07-06 -------------------- * Revise Alarm manager to send via RMR wormhole (`RIC-529 `_) + [1.3.0] - 2020-06-24 -------------------- * Add configuration-change API (`RIC-425 `_) diff --git a/ricxappframe/metric/__init__.py b/ricxappframe/metric/__init__.py new file mode 100644 index 0000000..82aac02 --- /dev/null +++ b/ricxappframe/metric/__init__.py @@ -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 index 0000000..d9b4cb2 --- /dev/null +++ b/ricxappframe/metric/exceptions.py @@ -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 index 0000000..7c4a878 --- /dev/null +++ b/ricxappframe/metric/metric-schema.json @@ -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 index 0000000..14186dd --- /dev/null +++ b/ricxappframe/metric/metric.py @@ -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)) diff --git a/setup.py b/setup.py index d20eda6..dbd455e 100644 --- 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=[ diff --git a/tests/fixtures/test_local.rt b/tests/fixtures/test_local.rt index f9d14ce..b821a51 100644 --- a/tests/fixtures/test_local.rt +++ b/tests/fixtures/test_local.rt @@ -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 diff --git a/tests/test_alarm.py b/tests/test_alarm.py index 0cde0ce..df8b2d4 100644 --- a/tests/test_alarm.py +++ b/tests/test_alarm.py @@ -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 index 0000000..a40c978 --- /dev/null +++ b/tests/test_metric.py @@ -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) -- 2.16.6