diff --git a/CHANGELOG.md b/CHANGELOG.md index 99892765b8..6e3a1acbc1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `opentelemetry-instrumentation-grpc` add supports to filter requests to instrument. ([#1241](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/1241)) - Flask sqlalchemy psycopg2 integration ([#1224](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/1224)) +- Add metric instrumentation in Falcon + ([#1230](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/1230)) - Add metric instrumentation in fastapi ([#1199](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/1199)) - Add metric instrumentation in Pyramid diff --git a/instrumentation/opentelemetry-instrumentation-falcon/src/opentelemetry/instrumentation/falcon/__init__.py b/instrumentation/opentelemetry-instrumentation-falcon/src/opentelemetry/instrumentation/falcon/__init__.py index e4f97bce93..8a21ca429e 100644 --- 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: diff --git a/instrumentation/opentelemetry-instrumentation-falcon/tests/test_falcon.py b/instrumentation/opentelemetry-instrumentation-falcon/tests/test_falcon.py index cf02b52d82..7e714342a7 100644 --- 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):