Repository: open-telemetry/opentelemetry-python-contrib

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


Discussion:
Can you assign this to me?
I am a bit busy these days. Does someone wants to take up this issue? 
Hello @ashu658 
I'm willing to work on this issue. Can you please assign this to me?
Thanks @TheAnshul756 
Assigned!

Resolving patch:
diff --git a/instrumentation/opentelemetry-instrumentation-pyramid/src/opentelemetry/instrumentation/pyramid/callbacks.py b/instrumentation/opentelemetry-instrumentation-pyramid/src/opentelemetry/instrumentation/pyramid/callbacks.py
--- a/instrumentation/opentelemetry-instrumentation-pyramid/src/opentelemetry/instrumentation/pyramid/callbacks.py
+++ b/instrumentation/opentelemetry-instrumentation-pyramid/src/opentelemetry/instrumentation/pyramid/callbacks.py
@@ -14,6 +14,7 @@
 
 from logging import getLogger
 from time import time_ns
+from timeit import default_timer
 
 from pyramid.events import BeforeTraversal
 from pyramid.httpexceptions import HTTPException, HTTPServerError
@@ -27,6 +28,7 @@
 )
 from opentelemetry.instrumentation.pyramid.version import __version__
 from opentelemetry.instrumentation.utils import _start_internal_or_server_span
+from opentelemetry.metrics import get_meter
 from opentelemetry.semconv.trace import SpanAttributes
 from opentelemetry.util.http import get_excluded_urls
 
@@ -122,8 +124,20 @@ def _before_traversal(event):
 
 
 def trace_tween_factory(handler, registry):
+    # pylint: disable=too-many-statements
     settings = registry.settings
     enabled = asbool(settings.get(SETTING_TRACE_ENABLED, True))
+    meter = get_meter(__name__, __version__)
+    duration_histogram = meter.create_histogram(
+        name="http.server.duration",
+        unit="ms",
+        description="measures the duration of the inbound HTTP request",
+    )
+    active_requests_counter = 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",
+    )
 
     if not enabled:
         # If disabled, make a tween that signals to the
@@ -137,14 +151,23 @@ def disabled_tween(request):
     # make a request tracing function
     # pylint: disable=too-many-branches
     def trace_tween(request):
-        # pylint: disable=E1101
+        # pylint: disable=E1101, too-many-locals
         if _excluded_urls.url_disabled(request.url):
             request.environ[_ENVIRON_ENABLED_KEY] = False
             # short-circuit when we don't want to trace anything
             return handler(request)
 
+        attributes = otel_wsgi.collect_request_attributes(request.environ)
+
         request.environ[_ENVIRON_ENABLED_KEY] = True
         request.environ[_ENVIRON_STARTTIME_KEY] = time_ns()
+        active_requests_count_attrs = (
+            otel_wsgi._parse_active_request_count_attrs(attributes)
+        )
+        duration_attrs = otel_wsgi._parse_duration_attrs(attributes)
+
+        start = default_timer()
+        active_requests_counter.add(1, active_requests_count_attrs)
 
         response = None
         status = None
@@ -165,6 +188,15 @@ def trace_tween(request):
             status = "500 InternalServerError"
             raise
         finally:
+            duration = max(round((default_timer() - start) * 1000), 0)
+            status = getattr(response, "status", status)
+            status_code = otel_wsgi._parse_status_code(status)
+            if status_code is not None:
+                duration_attrs[
+                    SpanAttributes.HTTP_STATUS_CODE
+                ] = otel_wsgi._parse_status_code(status)
+            duration_histogram.record(duration, duration_attrs)
+            active_requests_counter.add(-1, active_requests_count_attrs)
             span = request.environ.get(_ENVIRON_SPAN_KEY)
             enabled = request.environ.get(_ENVIRON_ENABLED_KEY)
             if not span and enabled:
@@ -174,7 +206,6 @@ def trace_tween(request):
                     "PyramidInstrumentor().instrument_config(config) is called"
                 )
             elif enabled:
-                status = getattr(response, "status", status)
 
                 if status is not None:
                     otel_wsgi.add_response_attributes(


Test patch:
diff --git a/instrumentation/opentelemetry-instrumentation-pyramid/tests/test_automatic.py b/instrumentation/opentelemetry-instrumentation-pyramid/tests/test_automatic.py
--- a/instrumentation/opentelemetry-instrumentation-pyramid/tests/test_automatic.py
+++ b/instrumentation/opentelemetry-instrumentation-pyramid/tests/test_automatic.py
@@ -12,12 +12,17 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 
+from timeit import default_timer
 from unittest.mock import patch
 
 from pyramid.config import Configurator
 
 from opentelemetry import trace
 from opentelemetry.instrumentation.pyramid import PyramidInstrumentor
+from opentelemetry.sdk.metrics.export import (
+    HistogramDataPoint,
+    NumberDataPoint,
+)
 from opentelemetry.test.globals_test import reset_trace_globals
 from opentelemetry.test.wsgitestutil import WsgiTestBase
 from opentelemetry.trace import SpanKind
@@ -25,11 +30,22 @@
 from opentelemetry.util.http import (
     OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SERVER_REQUEST,
     OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SERVER_RESPONSE,
+    _active_requests_count_attrs,
+    _duration_attrs,
 )
 
 # pylint: disable=import-error
 from .pyramid_base_test import InstrumentationTest
 
+_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 TestAutomatic(InstrumentationTest, WsgiTestBase):
     def setUp(self):
@@ -156,6 +172,89 @@ def test_400s_response_is_not_an_error(self):
         span_list = self.memory_exporter.get_finished_spans()
         self.assertEqual(len(span_list), 1)
 
+    def test_pyramid_metric(self):
+        self.client.get("/hello/756")
+        self.client.get("/hello/756")
+        self.client.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) == 1)
+        for resource_metric in metrics_list.resource_metrics:
+            self.assertTrue(len(resource_metric.scope_metrics) == 1)
+            for scope_metric in resource_metric.scope_metrics:
+                self.assertTrue(len(scope_metric.metrics) == 2)
+                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_basic_metric_success(self):
+        start = default_timer()
+        self.client.get("/hello/756")
+        duration = max(round((default_timer() - start) * 1000), 0)
+        expected_duration_attributes = {
+            "http.method": "GET",
+            "http.host": "localhost",
+            "http.scheme": "http",
+            "http.flavor": "1.1",
+            "http.server_name": "localhost",
+            "net.host.port": 80,
+            "http.status_code": 200,
+        }
+        expected_requests_count_attributes = {
+            "http.method": "GET",
+            "http.host": "localhost",
+            "http.scheme": "http",
+            "http.flavor": "1.1",
+            "http.server_name": "localhost",
+        }
+        metrics_list = self.memory_metrics_reader.get_metrics_data()
+        for metric in (
+            metrics_list.resource_metrics[0].scope_metrics[0].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=20)
+                if isinstance(point, NumberDataPoint):
+                    self.assertDictEqual(
+                        expected_requests_count_attributes,
+                        dict(point.attributes),
+                    )
+                    self.assertEqual(point.value, 0)
+
+    def test_metric_uninstruemnt(self):
+        self.client.get("/hello/756")
+        PyramidInstrumentor().uninstrument()
+        self.config = Configurator()
+        self._common_initialization(self.config)
+        self.client.get("/hello/756")
+        metrics_list = self.memory_metrics_reader.get_metrics_data()
+        for metric in (
+            metrics_list.resource_metrics[0].scope_metrics[0].metrics
+        ):
+            for point in list(metric.data.data_points):
+                if isinstance(point, HistogramDataPoint):
+                    self.assertEqual(point.count, 1)
+                if isinstance(point, NumberDataPoint):
+                    self.assertEqual(point.value, 0)
+
 
 class TestWrappedWithOtherFramework(InstrumentationTest, WsgiTestBase):
     def setUp(self):