diff --git a/CHANGELOG.md b/CHANGELOG.md index 10d6d27c21..cd97ac8893 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Flask sqlalchemy psycopg2 integration ([#1224](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/1224)) +- Add metric instrumentation in Pyramid + ([#1242](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/1242)) ### Fixed diff --git a/instrumentation/opentelemetry-instrumentation-pyramid/src/opentelemetry/instrumentation/pyramid/callbacks.py b/instrumentation/opentelemetry-instrumentation-pyramid/src/opentelemetry/instrumentation/pyramid/callbacks.py index c9ebf7081e..cb650715a9 100644 --- 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( diff --git a/instrumentation/opentelemetry-instrumentation-pyramid/tests/test_automatic.py b/instrumentation/opentelemetry-instrumentation-pyramid/tests/test_automatic.py index 89df49e49e..93ec314f97 100644 --- 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):