Repository: rycus86/prometheus_flask_exporter

Issue:
Is it possible to give a metric an inital value?
I want to monitor (the rate of) api calls to a microservice.
The problem that I run into is that a metric is only available _after_ the first HTTP request.
This means that the first time that Prometheus sees the metric of an endpoint the value will be 1.
The consequence is that Prometheus cannot 'correctly' calculate the `rate` or `increase` for the metric for the first HTTP request since as far as Prometheus knows the value has always been 1.

Is it possible to make the metric available _before_ the first request? (e.g. start counting at zero).

`/metrics` output before any api call.
```
# HELP python_gc_objects_collected_total Objects collected during gc
# TYPE python_gc_objects_collected_total counter
python_gc_objects_collected_total{generation="0"} 413.0
python_gc_objects_collected_total{generation="1"} 0.0
python_gc_objects_collected_total{generation="2"} 0.0
# HELP python_gc_objects_uncollectable_total Uncollectable object found during GC
# TYPE python_gc_objects_uncollectable_total counter
python_gc_objects_uncollectable_total{generation="0"} 0.0
python_gc_objects_uncollectable_total{generation="1"} 0.0
python_gc_objects_uncollectable_total{generation="2"} 0.0
# HELP python_gc_collections_total Number of times this generation was collected
# TYPE python_gc_collections_total counter
python_gc_collections_total{generation="0"} 77.0
python_gc_collections_total{generation="1"} 7.0
python_gc_collections_total{generation="2"} 0.0
# HELP python_info Python platform information
# TYPE python_info gauge
python_info{implementation="CPython",major="3",minor="10",patchlevel="4",version="3.10.4"} 1.0
# HELP process_virtual_memory_bytes Virtual memory size in bytes.
# TYPE process_virtual_memory_bytes gauge
process_virtual_memory_bytes 7.2278016e+07
# HELP process_resident_memory_bytes Resident memory size in bytes.
# TYPE process_resident_memory_bytes gauge
process_resident_memory_bytes 3.3284096e+07
# HELP process_start_time_seconds Start time of the process since unix epoch in seconds.
# TYPE process_start_time_seconds gauge
process_start_time_seconds 1.66747764407e+09
# HELP process_cpu_seconds_total Total user and system CPU time spent in seconds.
# TYPE process_cpu_seconds_total counter
process_cpu_seconds_total 2.54
# HELP process_open_fds Number of open file descriptors.
# TYPE process_open_fds gauge
process_open_fds 10.0
# HELP process_max_fds Maximum number of open file descriptors.
# TYPE process_max_fds gauge
process_max_fds 1.048576e+06
# HELP exporter_info Information about the Prometheus Flask exporter
# TYPE exporter_info gauge
exporter_info{version="0.20.3"} 1.0
# HELP http_request_duration_seconds Flask HTTP request duration in seconds
# TYPE http_request_duration_seconds histogram
# HELP http_request_total Total number of HTTP requests
# TYPE http_request_total counter
# HELP http_request_exceptions_total Total number of HTTP requests which resulted in an exception
# TYPE http_request_exceptions_total counter
# HELP by_path_counter_total Request count by request paths
# TYPE by_path_counter_total counter
```

`/metrics` output after first api call
```
# HELP python_gc_objects_collected_total Objects collected during gc
# TYPE python_gc_objects_collected_total counter
python_gc_objects_collected_total{generation="0"} 413.0
python_gc_objects_collected_total{generation="1"} 0.0
python_gc_objects_collected_total{generation="2"} 0.0
# HELP python_gc_objects_uncollectable_total Uncollectable object found during GC
# TYPE python_gc_objects_uncollectable_total counter
python_gc_objects_uncollectable_total{generation="0"} 0.0
python_gc_objects_uncollectable_total{generation="1"} 0.0
python_gc_objects_uncollectable_total{generation="2"} 0.0
# HELP python_gc_collections_total Number of times this generation was collected
# TYPE python_gc_collections_total counter
python_gc_collections_total{generation="0"} 77.0
python_gc_collections_total{generation="1"} 7.0
python_gc_collections_total{generation="2"} 0.0
# HELP python_info Python platform information
# TYPE python_info gauge
python_info{implementation="CPython",major="3",minor="10",patchlevel="4",version="3.10.4"} 1.0
# HELP process_virtual_memory_bytes Virtual memory size in bytes.
# TYPE process_virtual_memory_bytes gauge
process_virtual_memory_bytes 7.2278016e+07
# HELP process_resident_memory_bytes Resident memory size in bytes.
# TYPE process_resident_memory_bytes gauge
process_resident_memory_bytes 3.3284096e+07
# HELP process_start_time_seconds Start time of the process since unix epoch in seconds.
# TYPE process_start_time_seconds gauge
process_start_time_seconds 1.66747764407e+09
# HELP process_cpu_seconds_total Total user and system CPU time spent in seconds.
# TYPE process_cpu_seconds_total counter
process_cpu_seconds_total 2.54
# HELP process_open_fds Number of open file descriptors.
# TYPE process_open_fds gauge
process_open_fds 10.0
# HELP process_max_fds Maximum number of open file descriptors.
# TYPE process_max_fds gauge
process_max_fds 1.048576e+06
# HELP exporter_info Information about the Prometheus Flask exporter
# TYPE exporter_info gauge
exporter_info{version="0.20.3"} 1.0
# HELP http_request_duration_seconds Flask HTTP request duration in seconds
# TYPE http_request_duration_seconds histogram
http_request_duration_seconds_bucket{le="0.005",method="GET",microservice_type="placeholder",model_name="devmodel",path="/api/example",status="503"} 1.0
http_request_duration_seconds_bucket{le="0.01",method="GET",microservice_type="placeholder",model_name="devmodel",path="/api/example",status="503"} 1.0
http_request_duration_seconds_bucket{le="0.025",method="GET",microservice_type="placeholder",model_name="devmodel",path="/api/example",status="503"} 1.0
http_request_duration_seconds_bucket{le="0.05",method="GET",microservice_type="placeholder",model_name="devmodel",path="/api/example",status="503"} 1.0
http_request_duration_seconds_bucket{le="0.075",method="GET",microservice_type="placeholder",model_name="devmodel",path="/api/example",status="503"} 1.0
http_request_duration_seconds_bucket{le="0.1",method="GET",microservice_type="placeholder",model_name="devmodel",path="/api/example",status="503"} 1.0
http_request_duration_seconds_bucket{le="0.25",method="GET",microservice_type="placeholder",model_name="devmodel",path="/api/example",status="503"} 1.0
http_request_duration_seconds_bucket{le="0.5",method="GET",microservice_type="placeholder",model_name="devmodel",path="/api/example",status="503"} 1.0
http_request_duration_seconds_bucket{le="0.75",method="GET",microservice_type="placeholder",model_name="devmodel",path="/api/example",status="503"} 1.0
http_request_duration_seconds_bucket{le="1.0",method="GET",microservice_type="placeholder",model_name="devmodel",path="/api/example",status="503"} 1.0
http_request_duration_seconds_bucket{le="2.5",method="GET",microservice_type="placeholder",model_name="devmodel",path="/api/example",status="503"} 1.0
http_request_duration_seconds_bucket{le="5.0",method="GET",microservice_type="placeholder",model_name="devmodel",path="/api/example",status="503"} 1.0
http_request_duration_seconds_bucket{le="7.5",method="GET",microservice_type="placeholder",model_name="devmodel",path="/api/example",status="503"} 1.0
http_request_duration_seconds_bucket{le="10.0",method="GET",microservice_type="placeholder",model_name="devmodel",path="/api/example",status="503"} 1.0
http_request_duration_seconds_bucket{le="+Inf",method="GET",microservice_type="placeholder",model_name="devmodel",path="/api/example",status="503"} 1.0
http_request_duration_seconds_count{method="GET",microservice_type="placeholder",model_name="devmodel",path="/api/example",status="503"} 1.0
http_request_duration_seconds_sum{method="GET",microservice_type="placeholder",model_name="devmodel",path="/api/example",status="503"} 0.0003939999733120203
# HELP http_request_duration_seconds_created Flask HTTP request duration in seconds
# TYPE http_request_duration_seconds_created gauge
http_request_duration_seconds_created{method="GET",microservice_type="placeholder",model_name="devmodel",path="/api/example",status="503"} 1.6674777541179776e+09
# HELP http_request_total Total number of HTTP requests
# TYPE http_request_total counter
http_request_total{method="GET",microservice_type="placeholder",model_name="devmodel",status="503"} 1.0
# HELP http_request_created Total number of HTTP requests
# TYPE http_request_created gauge
http_request_created{method="GET",microservice_type="placeholder",model_name="devmodel",status="503"} 1.6674777541180773e+09
# HELP http_request_exceptions_total Total number of HTTP requests which resulted in an exception
# TYPE http_request_exceptions_total counter
# HELP by_path_counter_total Request count by request paths
# TYPE by_path_counter_total counter
by_path_counter_total{method="GET",microservice_type="placeholder",model_name="devmodel",path="/api/example",status="503"} 1.0
# HELP by_path_counter_created Request count by request paths
# TYPE by_path_counter_created gauge
by_path_counter_created{method="GET",microservice_type="placeholder",model_name="devmodel",path="/api/example",status="503"} 1.6674777541178892e+09
```

Discussion:

Patch:
diff --git a/.gitignore b/.gitignore
index f40e136..7ac4730 100644
--- a/.gitignore
+++ b/.gitignore
@@ -4,3 +4,4 @@
 *.pyc
 .coverage
 coverage.xml
+*.egg-info/
diff --git a/README.md b/README.md
index 25ffe62..9678ec8 100644
--- a/README.md
+++ b/README.md
@@ -226,6 +226,13 @@ the following values are supported in the dictionary:
 
 Label values are evaluated within the request context.
 
+## Initial metric values
+_For more info see: https://github.com/prometheus/client_python#labels_
+
+Metrics without any labels will get an initial value.
+Metrics that only have static-value labels will also have an initial value. (except when they are created with the option `initial_value_when_only_static_labels=False`)
+Metrics that have one or more callable-value labels will not have an initial value.
+
 ## Application information
 
 The `PrometheusMetrics.info(..)` method provides a way to expose
diff --git a/prometheus_flask_exporter/__init__.py b/prometheus_flask_exporter/__init__.py
index 37248d5..e194d0f 100644
--- a/prometheus_flask_exporter/__init__.py
+++ b/prometheus_flask_exporter/__init__.py
@@ -560,7 +560,7 @@ class PrometheusMetrics(object):
                 view_func = wrapper(view_func)
                 app.view_functions[endpoint] = view_func
 
-    def histogram(self, name, description, labels=None, **kwargs):
+    def histogram(self, name, description, labels=None, initial_value_when_only_static_labels=True, **kwargs):
         """
         Use a Histogram to track the execution time and invocation count
         of the method.
@@ -568,6 +568,8 @@ class PrometheusMetrics(object):
         :param name: the name of the metric
         :param description: the description of the metric
         :param labels: a dictionary of `{labelname: callable_or_value}` for labels
+        :param initial_value_when_only_static_labels: whether to give metric an initial value
+            when only static labels are present
         :param kwargs: additional keyword arguments for creating the Histogram
         """
 
@@ -575,10 +577,11 @@ class PrometheusMetrics(object):
             Histogram,
             lambda metric, time: metric.observe(time),
             kwargs, name, description, labels,
+            initial_value_when_only_static_labels=initial_value_when_only_static_labels,
             registry=self.registry
         )
 
-    def summary(self, name, description, labels=None, **kwargs):
+    def summary(self, name, description, labels=None, initial_value_when_only_static_labels=True, **kwargs):
         """
         Use a Summary to track the execution time and invocation count
         of the method.
@@ -586,6 +589,8 @@ class PrometheusMetrics(object):
         :param name: the name of the metric
         :param description: the description of the metric
         :param labels: a dictionary of `{labelname: callable_or_value}` for labels
+        :param initial_value_when_only_static_labels: whether to give metric an initial value
+            when only static labels are present
         :param kwargs: additional keyword arguments for creating the Summary
         """
 
@@ -593,10 +598,11 @@ class PrometheusMetrics(object):
             Summary,
             lambda metric, time: metric.observe(time),
             kwargs, name, description, labels,
+            initial_value_when_only_static_labels=initial_value_when_only_static_labels,
             registry=self.registry
         )
 
-    def gauge(self, name, description, labels=None, **kwargs):
+    def gauge(self, name, description, labels=None, initial_value_when_only_static_labels=True, **kwargs):
         """
         Use a Gauge to track the number of invocations in progress
         for the method.
@@ -604,6 +610,8 @@ class PrometheusMetrics(object):
         :param name: the name of the metric
         :param description: the description of the metric
         :param labels: a dictionary of `{labelname: callable_or_value}` for labels
+        :param initial_value_when_only_static_labels: whether to give metric an initial value
+            when only static labels are present
         :param kwargs: additional keyword arguments for creating the Gauge
         """
 
@@ -611,30 +619,37 @@ class PrometheusMetrics(object):
             Gauge,
             lambda metric, time: metric.dec(),
             kwargs, name, description, labels,
+            initial_value_when_only_static_labels=initial_value_when_only_static_labels,
             registry=self.registry,
             before=lambda metric: metric.inc(),
             revert_when_not_tracked=lambda metric: metric.dec()
         )
 
-    def counter(self, name, description, labels=None, **kwargs):
+    def counter(self, name, description, labels=None, initial_value_when_only_static_labels=True, **kwargs):
         """
         Use a Counter to track the total number of invocations of the method.
 
         :param name: the name of the metric
         :param description: the description of the metric
         :param labels: a dictionary of `{labelname: callable_or_value}` for labels
+        :param initial_value_when_only_static_labels: whether to give metric an initial value
+            when only static labels are present
         :param kwargs: additional keyword arguments for creating the Counter
         """
 
         return self._track(
             Counter,
             lambda metric, time: metric.inc(),
-            kwargs, name, description, labels,
+            kwargs,
+            name,
+            description,
+            labels,
+            initial_value_when_only_static_labels=initial_value_when_only_static_labels,
             registry=self.registry
         )
 
     def _track(self, metric_type, metric_call, metric_kwargs, name, description, labels,
-               registry, before=None, revert_when_not_tracked=None):
+               initial_value_when_only_static_labels, registry, before=None, revert_when_not_tracked=None):
         """
         Internal method decorator logic.
 
@@ -644,6 +659,8 @@ class PrometheusMetrics(object):
         :param name: the name of the metric
         :param description: the description of the metric
         :param labels: a dictionary of `{labelname: callable_or_value}` for labels
+        :param initial_value_when_only_static_labels: whether to give metric an initial value
+            when only static labels are present
         :param registry: the Prometheus Registry to use
         :param before: an optional callable to invoke before executing the
             request handler method accepting the single `metric` argument
@@ -662,6 +679,11 @@ class PrometheusMetrics(object):
             **metric_kwargs
         )
 
+        # When all labels are already known at this point, the metric can get an initial value.
+        if initial_value_when_only_static_labels and labels.labels:
+            if all([label is not callable for label in labels.labels]):
+                parent_metric.labels(*[value for label, value in labels.labels])
+
         def get_metric(response):
             if labels.has_keys():
                 return parent_metric.labels(**labels.values_for(response))


Test patch:
diff --git a/tests/test_metric_initialization.py b/tests/test_metric_initialization.py
new file mode 100644
index 0000000..2ed7974
--- /dev/null
+++ b/tests/test_metric_initialization.py
@@ -0,0 +1,103 @@
+from abc import ABC, abstractmethod
+
+from flask import request
+
+from unittest_helper import BaseTestCase
+
+
+# The class nesting avoids that the abstract base class will be tested (which is not possible because it is abstract..)
+class MetricInitializationTest:
+    class MetricInitializationTest(BaseTestCase, ABC):
+        metric_suffix = None
+
+        @property
+        @abstractmethod
+        def metric_type(self):
+            pass
+
+        def get_metric_decorator(self, metrics):
+            return getattr(metrics, self.metric_type)
+
+        def _test_metric_initialization(self, labels=None, initial_value_when_only_static_labels=True):
+            metrics = self.metrics()
+            metric_decorator = self.get_metric_decorator(metrics)
+
+            test_path = '/test/1'
+
+            @self.app.route(test_path)
+            @metric_decorator('metric_1', 'Metric 1',
+                              labels=labels,
+                              initial_value_when_only_static_labels=initial_value_when_only_static_labels)
+            def test1():
+                return 'OK'
+
+            if labels:
+                # replace callable with the "expected" result
+                if 'path' in labels:
+                    labels['path'] = test_path
+
+                label_value_pairs = labels.items()
+            else:
+                label_value_pairs = []
+
+            prometheus_metric_name = 'metric_1'
+            if self.metric_suffix:
+                prometheus_metric_name += self.metric_suffix
+
+            # test metric value before any incoming HTTP call
+            self.assertMetric(prometheus_metric_name, '0.0', *label_value_pairs)
+
+            self.client.get('/test/1')
+
+            if self.metric_type == 'gauge':
+                expected_metric_value = '0.0'
+            else:
+                expected_metric_value = '1.0'
+
+            self.assertMetric(prometheus_metric_name, expected_metric_value, *label_value_pairs)
+
+        def test_initial_value_no_labels(self):
+            self._test_metric_initialization()
+
+        def test_initial_value_only_static_labels(self):
+            labels = {'label_name': 'label_value'}
+            self._test_metric_initialization(labels)
+
+        def test_initial_value_only_static_labels_no_initialization(self):
+            labels = {'label_name': 'label_value'}
+            self.assertRaises(AssertionError, self._test_metric_initialization, labels, initial_value_when_only_static_labels=False)
+
+        def test_initial_value_callable_label(self):
+            labels = {'path': lambda: request.path}
+            self.assertRaises(AssertionError, self._test_metric_initialization, labels)
+
+
+
+class HistogramInitializationTest(MetricInitializationTest.MetricInitializationTest):
+    metric_suffix = '_count'
+
+    @property
+    def metric_type(self):
+        return 'histogram'
+
+
+class SummaryInitializationTest(MetricInitializationTest.MetricInitializationTest):
+    metric_suffix = '_count'
+
+    @property
+    def metric_type(self):
+        return 'summary'
+
+
+class GaugeInitializationTest(MetricInitializationTest.MetricInitializationTest):
+    @property
+    def metric_type(self):
+        return 'gauge'
+
+
+class CounterInitializationTest(MetricInitializationTest.MetricInitializationTest):
+    metric_suffix = '_total'
+
+    @property
+    def metric_type(self):
+        return 'counter'
diff --git a/tests/test_metrics.py b/tests/test_metrics.py
index 6582940..0e6190c 100644
--- a/tests/test_metrics.py
+++ b/tests/test_metrics.py
@@ -28,6 +28,8 @@ class MetricsTest(BaseTestCase):
         def test3(x, y):
             return 'OK: %d/%d' % (x, y)
 
+        self.assertMetric('hist_1_count', '0.0')
+
         self.client.get('/test/1')
 
         self.assertMetric('hist_1_count', '1.0')
@@ -72,6 +74,8 @@ class MetricsTest(BaseTestCase):
         def test2():
             return 'OK'
 
+        self.assertMetric('sum_1_count', '0.0')
+
         self.client.get('/test/1')
 
         self.assertMetric('sum_1_count', '1.0')
@@ -106,6 +110,8 @@ class MetricsTest(BaseTestCase):
 
             return 'OK: %d' % a
 
+        self.assertMetric('gauge_1', '0.0')
+
         self.client.get('/test/1')
 
         self.assertMetric('gauge_1', '0.0')
@@ -133,6 +139,7 @@ class MetricsTest(BaseTestCase):
         def test2():
             return 'OK'
 
+        self.assertMetric('cnt_1_total', '0.0')
         self.client.get('/test/1')
         self.assertMetric('cnt_1_total', '1.0')
         self.client.get('/test/1')