#412 Add /api/1/metrics/ with total number of composes and raw_config composes.
Merged 3 years ago by lsedlar. Opened 3 years ago by jkaluza.
jkaluza/odcs metrics  into  master

@@ -0,0 +1,96 @@ 

+ # -*- coding: utf-8 -*-

+ 

+ # Copyright (c) 2020  Red Hat, Inc.

+ #

+ # Permission is hereby granted, free of charge, to any person obtaining a copy

+ # of this software and associated documentation files (the "Software"), to deal

+ # in the Software without restriction, including without limitation the rights

+ # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell

+ # copies of the Software, and to permit persons to whom the Software is

+ # furnished to do so, subject to the following conditions:

+ #

+ # The above copyright notice and this permission notice shall be included in all

+ # copies or substantial portions of the Software.

+ #

+ # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR

+ # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,

+ # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE

+ # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER

+ # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,

+ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE

+ # SOFTWARE.

+ #

+ # Written by Jan Kaluza <jkaluza@redhat.com>

+ 

+ from sqlalchemy import func

+ from prometheus_client import CollectorRegistry

+ from prometheus_client.core import GaugeMetricFamily, CounterMetricFamily

+ 

+ from odcs.common.types import COMPOSE_STATES, PUNGI_SOURCE_TYPE_NAMES

+ from odcs.server.models import Compose

+ 

+ 

+ registry = CollectorRegistry()

+ 

+ 

+ class ComposesCollector(object):

+ 

+     def composes_total(self):

+         """

+         Returns `composes_total` GaugeMetricFamily with number of composes

+         for each state and source_type.

+         """

+         counter = GaugeMetricFamily(

+             "composes_total", "Total number of composes", labels=["source_type", "state"]

+         )

+         for state in COMPOSE_STATES:

+             for source_type in PUNGI_SOURCE_TYPE_NAMES:

+                 count = Compose.query.filter(

+                     Compose.source_type == PUNGI_SOURCE_TYPE_NAMES[source_type],

+                     Compose.state == COMPOSE_STATES[state],

+                 ).count()

+ 

+                 counter.add_metric([source_type, state], count)

+         return counter

+ 

+     def raw_config_composes_count(self):

+         """

+         Returns `raw_config_composes_count` CounterMetricFamily with number of raw_config

+         composes for each `Compose.source`. For raw_config composes, the Compose.source is

+         stored in the `raw_config_key#commit_or_branch` format. If particular `Compose.source` is

+         generated only few times (less than 5), it is grouped by the `raw_config_key` and

+         particular `commit_or_branch` is replaced with "other_commits_or_branches" string.

+ 

+         This is needed to handle the situation when particular raw_config compose is generated

+         just once using particular commit hash (and not a branch name). These single composes

+         are not that important in the metrics and therefore we group them like that.

+         """

+         counter = CounterMetricFamily(

+             "raw_config_composes_count",

+             "Total number of raw_config composes per source", labels=["source"]

+         )

+         composes = Compose.query.with_entities(Compose.source, func.count(Compose.source)).filter(

+             Compose.source_type == PUNGI_SOURCE_TYPE_NAMES["raw_config"]

+         ).group_by(Compose.source).all()

+ 

+         sources = {}

+         for source, count in composes:

+             if count < 5:

+                 name = "%s#other_commits_or_branches" % source.split("#")[0]

+                 if name not in sources:

+                     sources[name] = 0

+                 sources[name] += count

+             else:

+                 sources[source] = count

+ 

+         for source, count in sources.items():

+             counter.add_metric([source], count)

+ 

+         return counter

+ 

+     def collect(self):

+         yield self.composes_total()

+         yield self.raw_config_composes_count()

+ 

+ 

+ registry.register(ComposesCollector())

file modified
+25 -1
@@ -24,8 +24,9 @@ 

  import datetime

  

  from flask.views import MethodView, View

- from flask import render_template, request, jsonify, g

+ from flask import render_template, request, jsonify, g, Response

  from werkzeug.exceptions import BadRequest

+ from prometheus_client import generate_latest, CONTENT_TYPE_LATEST

  

  from odcs.server import app, db, log, conf, version

  from odcs.server.errors import NotFound, Forbidden
@@ -38,6 +39,7 @@ 

      raise_if_input_not_allowed)

  from odcs.server.auth import requires_role, login_required, has_role

  from odcs.server.auth import require_scopes

+ from odcs.server.metrics import registry

  

  try:

      from odcs.server.celery_tasks import celery_app, schedule_compose
@@ -86,6 +88,12 @@ 

              'methods': ['GET']

          }

      },

+     'metrics': {

+         'url': '/api/1/metrics/',

+         'options': {

+             'methods': ['GET']

+         }

+     },

  }

  

  
@@ -577,10 +585,21 @@ 

          return render_template('index.html')

  

  

+ class MetricsAPI(MethodView):

+     def get(self):

+         """

+         Returns the Prometheus metrics.

+ 

+         :statuscode 200: Prometheus metrics returned.

+         """

+         return Response(generate_latest(registry), content_type=CONTENT_TYPE_LATEST)

+ 

+ 

  def register_api_v1():

      """ Registers version 1 of ODCS API. """

      composes_view = ODCSAPI.as_view('composes')

      about_view = AboutAPI.as_view('about')

+     metrics_view = MetricsAPI.as_view('metrics')

      for key, val in api_v1.items():

          if key.startswith("compose"):

              app.add_url_rule(val['url'],
@@ -592,6 +611,11 @@ 

                               endpoint=key,

                               view_func=about_view,

                               **val['options'])

+         elif key.startswith("metrics"):

+             app.add_url_rule(val['url'],

+                              endpoint=key,

+                              view_func=metrics_view,

+                              **val['options'])

          else:

              raise ValueError("Unhandled API key: %s." % key)

  

file modified
+1
@@ -23,3 +23,4 @@ 

  koji

  pyldap

  celery

+ prometheus_client

@@ -0,0 +1,71 @@ 

+ # Copyright (c) 2020  Red Hat, Inc.

+ #

+ # Permission is hereby granted, free of charge, to any person obtaining a copy

+ # of this software and associated documentation files (the "Software"), to deal

+ # in the Software without restriction, including without limitation the rights

+ # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell

+ # copies of the Software, and to permit persons to whom the Software is

+ # furnished to do so, subject to the following conditions:

+ #

+ # The above copyright notice and this permission notice shall be included in all

+ # copies or substantial portions of the Software.

+ #

+ # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR

+ # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,

+ # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE

+ # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER

+ # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,

+ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE

+ # SOFTWARE.

+ #

+ # Written by Jan Kaluza <jkaluza@redhat.com>

+ 

+ from odcs.server import db

+ from odcs.server.models import Compose

+ from odcs.common.types import COMPOSE_RESULTS

+ from odcs.server.pungi import PungiSourceType

+ from odcs.server.metrics import ComposesCollector

+ from .utils import ModelsBaseTest

+ 

+ 

+ class TestComposesCollector(ModelsBaseTest):

+ 

+     def setUp(self):

+         super(TestComposesCollector, self).setUp()

+         self.collector = ComposesCollector()

+ 

+     def test_composes_total(self):

+         Compose.create(

+             db.session, "unknown", PungiSourceType.MODULE, "testmodule:master",

+             COMPOSE_RESULTS["repository"], 60)

+         Compose.create(

+             db.session, "me", PungiSourceType.KOJI_TAG, "f26",

+             COMPOSE_RESULTS["repository"], 60)

+         db.session.commit()

+ 

+         r = self.collector.composes_total()

+         for sample in r.samples:

+             if (

+                 sample.labels["source_type"] in ["module", "tag"] and

+                 sample.labels["state"] == "wait"

+             ):

+                 self.assertEqual(sample.value, 1)

+             else:

+                 self.assertEqual(sample.value, 0)

+ 

+     def test_raw_config_composes_count(self):

+         for i in range(15):

+             Compose.create(

+                 db.session, "unknown", PungiSourceType.RAW_CONFIG, "foo#bar",

+                 COMPOSE_RESULTS["repository"], 60)

+         for i in range(10):

+             Compose.create(

+                 db.session, "me", PungiSourceType.RAW_CONFIG, "foo#hash%d" % i,

+                 COMPOSE_RESULTS["repository"], 60)

+         db.session.commit()

+         r = self.collector.raw_config_composes_count()

+         for sample in r.samples:

+             if sample.labels["source"] == "foo#bar":

+                 self.assertEqual(sample.value, 15)

+             elif sample.labels["source"] == "foo#other_commits_or_branches":

+                 self.assertEqual(sample.value, 10)

@@ -266,6 +266,11 @@ 

              db.session.add(self.c2)

              db.session.commit()

  

+     def test_metrics(self):

+         rv = self.client.get('/api/1/metrics/')

+         data = rv.get_data(as_text=True)

+         self.assertTrue("HELP composes_total Total number of composes" in data)

+ 

      def test_index(self):

          rv = self.client.get('/')

          self.assertEqual(rv.status_code, 200)