Repository: open-telemetry/opentelemetry-python-contrib

Issue:
Metrics instrumentation falcon
HTTP metrics: https://github.com/open-telemetry/opentelemetry-specification/blob/main/specification/metrics/semantic_conventions/http-metrics.md


Discussion:
@srikanthccv Assign this to me.

Resolving patch:
diff --git a/instrumentation/opentelemetry-instrumentation-falcon/src/opentelemetry/instrumentation/falcon/__init__.py b/instrumentation/opentelemetry-instrumentation-falcon/src/opentelemetry/instrumentation/falcon/__init__.py
--- a/instrumentation/opentelemetry-instrumentation-falcon/src/opentelemetry/instrumentation/falcon/__init__.py
+++ b/instrumentation/opentelemetry-instrumentation-falcon/src/opentelemetry/instrumentation/falcon/__init__.py
@@ -144,6 +144,7 @@ def response_hook(span, req, resp):
 from logging import getLogger
 from sys import exc_info
 from time import time_ns
+from timeit import default_timer
 from typing import Collection
 
 import falcon
@@ -163,6 +164,7 @@ def response_hook(span, req, resp):
     extract_attributes_from_object,
     http_status_to_status_code,
 )
+from opentelemetry.metrics import get_meter
 from opentelemetry.semconv.trace import SpanAttributes
 from opentelemetry.trace.status import Status
 from opentelemetry.util.http import get_excluded_urls, get_traced_request_attrs
@@ -202,12 +204,24 @@ def __init__(self, *args, **kwargs):
         # inject trace middleware
         self._middlewares_list = kwargs.pop("middleware", [])
         tracer_provider = otel_opts.pop("tracer_provider", None)
+        meter_provider = otel_opts.pop("meter_provider", None)
         if not isinstance(self._middlewares_list, (list, tuple)):
             self._middlewares_list = [self._middlewares_list]
 
         self._otel_tracer = trace.get_tracer(
             __name__, __version__, tracer_provider
         )
+        self._otel_meter = get_meter(__name__, __version__, meter_provider)
+        self.duration_histogram = self._otel_meter.create_histogram(
+            name="http.server.duration",
+            unit="ms",
+            description="measures the duration of the inbound HTTP request",
+        )
+        self.active_requests_counter = self._otel_meter.create_up_down_counter(
+            name="http.server.active_requests",
+            unit="requests",
+            description="measures the number of concurrent HTTP requests that are currently in-flight",
+        )
 
         trace_middleware = _TraceMiddleware(
             self._otel_tracer,
@@ -261,6 +275,7 @@ def _handle_exception(
 
     def __call__(self, env, start_response):
         # pylint: disable=E1101
+        # pylint: disable=too-many-locals
         if self._otel_excluded_urls.url_disabled(env.get("PATH_INFO", "/")):
             return super().__call__(env, start_response)
 
@@ -276,9 +291,14 @@ def __call__(self, env, start_response):
             context_carrier=env,
             context_getter=otel_wsgi.wsgi_getter,
         )
+        attributes = otel_wsgi.collect_request_attributes(env)
+        active_requests_count_attrs = (
+            otel_wsgi._parse_active_request_count_attrs(attributes)
+        )
+        duration_attrs = otel_wsgi._parse_duration_attrs(attributes)
+        self.active_requests_counter.add(1, active_requests_count_attrs)
 
         if span.is_recording():
-            attributes = otel_wsgi.collect_request_attributes(env)
             for key, value in attributes.items():
                 span.set_attribute(key, value)
             if span.is_recording() and span.kind == trace.SpanKind.SERVER:
@@ -302,6 +322,7 @@ def _start_response(status, response_headers, *args, **kwargs):
                 context.detach(token)
             return response
 
+        start = default_timer()
         try:
             return super().__call__(env, _start_response)
         except Exception as exc:
@@ -313,6 +334,13 @@ def _start_response(status, response_headers, *args, **kwargs):
             if token is not None:
                 context.detach(token)
             raise
+        finally:
+            duration_attrs[
+                SpanAttributes.HTTP_STATUS_CODE
+            ] = span.attributes.get(SpanAttributes.HTTP_STATUS_CODE)
+            duration = max(round((default_timer() - start) * 1000), 0)
+            self.duration_histogram.record(duration, duration_attrs)
+            self.active_requests_counter.add(-1, active_requests_count_attrs)
 
 
 class _TraceMiddleware:


Test patch:
diff --git a/instrumentation/opentelemetry-instrumentation-falcon/tests/test_falcon.py b/instrumentation/opentelemetry-instrumentation-falcon/tests/test_falcon.py
--- a/instrumentation/opentelemetry-instrumentation-falcon/tests/test_falcon.py
+++ b/instrumentation/opentelemetry-instrumentation-falcon/tests/test_falcon.py
@@ -12,6 +12,7 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 #
+from timeit import default_timer
 from unittest.mock import Mock, patch
 
 import pytest
@@ -26,6 +27,14 @@
     get_global_response_propagator,
     set_global_response_propagator,
 )
+from opentelemetry.instrumentation.wsgi import (
+    _active_requests_count_attrs,
+    _duration_attrs,
+)
+from opentelemetry.sdk.metrics.export import (
+    HistogramDataPoint,
+    NumberDataPoint,
+)
 from opentelemetry.sdk.resources import Resource
 from opentelemetry.semconv.trace import SpanAttributes
 from opentelemetry.test.test_base import TestBase
@@ -38,6 +47,15 @@
 
 from .app import make_app
 
+_expected_metric_names = [
+    "http.server.active_requests",
+    "http.server.duration",
+]
+_recommended_attrs = {
+    "http.server.active_requests": _active_requests_count_attrs,
+    "http.server.duration": _duration_attrs,
+}
+
 
 class TestFalconBase(TestBase):
     def setUp(self):
@@ -254,6 +272,87 @@ def test_uninstrument_after_instrument(self):
         spans = self.memory_exporter.get_finished_spans()
         self.assertEqual(len(spans), 0)
 
+    def test_falcon_metrics(self):
+        self.client().simulate_get("/hello/756")
+        self.client().simulate_get("/hello/756")
+        self.client().simulate_get("/hello/756")
+        metrics_list = self.memory_metrics_reader.get_metrics_data()
+        number_data_point_seen = False
+        histogram_data_point_seen = False
+        self.assertTrue(len(metrics_list.resource_metrics) != 0)
+        for resource_metric in metrics_list.resource_metrics:
+            self.assertTrue(len(resource_metric.scope_metrics) != 0)
+            for scope_metric in resource_metric.scope_metrics:
+                self.assertTrue(len(scope_metric.metrics) != 0)
+                for metric in scope_metric.metrics:
+                    self.assertIn(metric.name, _expected_metric_names)
+                    data_points = list(metric.data.data_points)
+                    self.assertEqual(len(data_points), 1)
+                    for point in data_points:
+                        if isinstance(point, HistogramDataPoint):
+                            self.assertEqual(point.count, 3)
+                            histogram_data_point_seen = True
+                        if isinstance(point, NumberDataPoint):
+                            number_data_point_seen = True
+                        for attr in point.attributes:
+                            self.assertIn(
+                                attr, _recommended_attrs[metric.name]
+                            )
+        self.assertTrue(number_data_point_seen and histogram_data_point_seen)
+
+    def test_falcon_metric_values(self):
+        expected_duration_attributes = {
+            "http.method": "GET",
+            "http.host": "falconframework.org",
+            "http.scheme": "http",
+            "http.flavor": "1.1",
+            "http.server_name": "falconframework.org",
+            "net.host.port": 80,
+            "http.status_code": 404,
+        }
+        expected_requests_count_attributes = {
+            "http.method": "GET",
+            "http.host": "falconframework.org",
+            "http.scheme": "http",
+            "http.flavor": "1.1",
+            "http.server_name": "falconframework.org",
+        }
+        start = default_timer()
+        self.client().simulate_get("/hello/756")
+        duration = max(round((default_timer() - start) * 1000), 0)
+        metrics_list = self.memory_metrics_reader.get_metrics_data()
+        for resource_metric in metrics_list.resource_metrics:
+            for scope_metric in resource_metric.scope_metrics:
+                for metric in scope_metric.metrics:
+                    for point in list(metric.data.data_points):
+                        if isinstance(point, HistogramDataPoint):
+                            self.assertDictEqual(
+                                expected_duration_attributes,
+                                dict(point.attributes),
+                            )
+                            self.assertEqual(point.count, 1)
+                            self.assertAlmostEqual(
+                                duration, point.sum, delta=10
+                            )
+                        if isinstance(point, NumberDataPoint):
+                            self.assertDictEqual(
+                                expected_requests_count_attributes,
+                                dict(point.attributes),
+                            )
+                            self.assertEqual(point.value, 0)
+
+    def test_metric_uninstrument(self):
+        self.client().simulate_request(method="POST", path="/hello/756")
+        FalconInstrumentor().uninstrument()
+        self.client().simulate_request(method="POST", path="/hello/756")
+        metrics_list = self.memory_metrics_reader.get_metrics_data()
+        for resource_metric in metrics_list.resource_metrics:
+            for scope_metric in resource_metric.scope_metrics:
+                for metric in scope_metric.metrics:
+                    for point in list(metric.data.data_points):
+                        if isinstance(point, HistogramDataPoint):
+                            self.assertEqual(point.count, 1)
+
 
 class TestFalconInstrumentationWithTracerProvider(TestBase):
     def setUp(self):