Metrics instrumentation flask (#1186)

This commit is contained in:
Anshul Asawa
2022-08-05 13:17:14 +05:30
committed by GitHub
parent 14077a95c5
commit ebe6d1804b
6 changed files with 214 additions and 18 deletions

View File

@ -19,6 +19,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- `opentelemetry-instrumentation-redis` add support to instrument RedisCluster clients
([#1177](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/1177))
- `opentelemetry-instrumentation-sqlalchemy` Added span for the connection phase ([#1133](https://github.com/open-telemetry/opentelemetry-python-contrib/issues/1133))
- Add metric instumentation for flask
([#1186](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/1186))
## [1.12.0rc2-0.32b0](https://github.com/open-telemetry/opentelemetry-python/releases/tag/v1.12.0rc2-0.32b0) - 2022-07-01

View File

@ -17,7 +17,7 @@
| [opentelemetry-instrumentation-elasticsearch](./opentelemetry-instrumentation-elasticsearch) | elasticsearch >= 2.0 | No
| [opentelemetry-instrumentation-falcon](./opentelemetry-instrumentation-falcon) | falcon >= 1.4.1, < 4.0.0 | No
| [opentelemetry-instrumentation-fastapi](./opentelemetry-instrumentation-fastapi) | fastapi ~= 0.58 | No
| [opentelemetry-instrumentation-flask](./opentelemetry-instrumentation-flask) | flask >= 1.0, < 3.0 | No
| [opentelemetry-instrumentation-flask](./opentelemetry-instrumentation-flask) | flask >= 1.0, < 3.0 | Yes
| [opentelemetry-instrumentation-grpc](./opentelemetry-instrumentation-grpc) | grpcio ~= 1.27 | No
| [opentelemetry-instrumentation-httpx](./opentelemetry-instrumentation-httpx) | httpx >= 0.18.0 | No
| [opentelemetry-instrumentation-jinja2](./opentelemetry-instrumentation-jinja2) | jinja2 >= 2.7, < 4.0 | No

View File

@ -141,6 +141,7 @@ API
"""
from logging import getLogger
from timeit import default_timer
from typing import Collection
import flask
@ -154,6 +155,7 @@ from opentelemetry.instrumentation.propagators import (
get_global_response_propagator,
)
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._time import _time_ns
from opentelemetry.util.http import get_excluded_urls, parse_excluded_urls
@ -165,7 +167,6 @@ _ENVIRON_SPAN_KEY = "opentelemetry-flask.span_key"
_ENVIRON_ACTIVATION_KEY = "opentelemetry-flask.activation_key"
_ENVIRON_TOKEN = "opentelemetry-flask.token"
_excluded_urls_from_env = get_excluded_urls("FLASK")
@ -178,13 +179,26 @@ def get_default_span_name():
return span_name
def _rewrapped_app(wsgi_app, response_hook=None, excluded_urls=None):
def _rewrapped_app(
wsgi_app,
active_requests_counter,
duration_histogram,
response_hook=None,
excluded_urls=None,
):
def _wrapped_app(wrapped_app_environ, start_response):
# We want to measure the time for route matching, etc.
# In theory, we could start the span here and use
# update_name later but that API is "highly discouraged" so
# we better avoid it.
wrapped_app_environ[_ENVIRON_STARTTIME_KEY] = _time_ns()
start = default_timer()
attributes = otel_wsgi.collect_request_attributes(wrapped_app_environ)
active_requests_count_attrs = (
otel_wsgi._parse_active_request_count_attrs(attributes)
)
duration_attrs = otel_wsgi._parse_duration_attrs(attributes)
active_requests_counter.add(1, active_requests_count_attrs)
def _start_response(status, response_headers, *args, **kwargs):
if flask.request and (
@ -204,6 +218,11 @@ def _rewrapped_app(wsgi_app, response_hook=None, excluded_urls=None):
otel_wsgi.add_response_attributes(
span, status, response_headers
)
status_code = otel_wsgi._parse_status_code(status)
if status_code is not None:
duration_attrs[
SpanAttributes.HTTP_STATUS_CODE
] = status_code
if (
span.is_recording()
and span.kind == trace.SpanKind.SERVER
@ -223,13 +242,19 @@ def _rewrapped_app(wsgi_app, response_hook=None, excluded_urls=None):
response_hook(span, status, response_headers)
return start_response(status, response_headers, *args, **kwargs)
return wsgi_app(wrapped_app_environ, _start_response)
result = wsgi_app(wrapped_app_environ, _start_response)
duration = max(round((default_timer() - start) * 1000), 0)
duration_histogram.record(duration, duration_attrs)
active_requests_counter.add(-1, active_requests_count_attrs)
return result
return _wrapped_app
def _wrapped_before_request(
request_hook=None, tracer=None, excluded_urls=None
request_hook=None,
tracer=None,
excluded_urls=None,
):
def _before_request():
if excluded_urls and excluded_urls.url_disabled(flask.request.url):
@ -278,7 +303,9 @@ def _wrapped_before_request(
return _before_request
def _wrapped_teardown_request(excluded_urls=None):
def _wrapped_teardown_request(
excluded_urls=None,
):
def _teardown_request(exc):
# pylint: disable=E1101
if excluded_urls and excluded_urls.url_disabled(flask.request.url):
@ -290,7 +317,6 @@ def _wrapped_teardown_request(excluded_urls=None):
# a way that doesn't run `before_request`, like when it is created
# with `app.test_request_context`.
return
if exc is None:
activation.__exit__(None, None, None)
else:
@ -310,6 +336,7 @@ class _InstrumentedFlask(flask.Flask):
_tracer_provider = None
_request_hook = None
_response_hook = None
_meter_provider = None
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
@ -317,8 +344,24 @@ class _InstrumentedFlask(flask.Flask):
self._original_wsgi_app = self.wsgi_app
self._is_instrumented_by_opentelemetry = True
meter = get_meter(
__name__, __version__, _InstrumentedFlask._meter_provider
)
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",
)
self.wsgi_app = _rewrapped_app(
self.wsgi_app,
active_requests_counter,
duration_histogram,
_InstrumentedFlask._response_hook,
excluded_urls=_InstrumentedFlask._excluded_urls,
)
@ -367,6 +410,8 @@ class FlaskInstrumentor(BaseInstrumentor):
if excluded_urls is None
else parse_excluded_urls(excluded_urls)
)
meter_provider = kwargs.get("meter_provider")
_InstrumentedFlask._meter_provider = meter_provider
flask.Flask = _InstrumentedFlask
def _uninstrument(self, **kwargs):
@ -379,6 +424,7 @@ class FlaskInstrumentor(BaseInstrumentor):
response_hook=None,
tracer_provider=None,
excluded_urls=None,
meter_provider=None,
):
if not hasattr(app, "_is_instrumented_by_opentelemetry"):
app._is_instrumented_by_opentelemetry = False
@ -389,9 +435,25 @@ class FlaskInstrumentor(BaseInstrumentor):
if excluded_urls is not None
else _excluded_urls_from_env
)
meter = get_meter(__name__, __version__, meter_provider)
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",
)
app._original_wsgi_app = app.wsgi_app
app.wsgi_app = _rewrapped_app(
app.wsgi_app, response_hook, excluded_urls=excluded_urls
app.wsgi_app,
active_requests_counter,
duration_histogram,
response_hook,
excluded_urls=excluded_urls,
)
tracer = trace.get_tracer(__name__, __version__, tracer_provider)

View File

@ -14,3 +14,5 @@
_instruments = ("flask >= 1.0, < 3.0",)
_supports_metrics = True

View File

@ -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
from flask import Flask, request
@ -23,7 +24,15 @@ from opentelemetry.instrumentation.propagators import (
get_global_response_propagator,
set_global_response_propagator,
)
from opentelemetry.instrumentation.wsgi import OpenTelemetryMiddleware
from opentelemetry.instrumentation.wsgi import (
OpenTelemetryMiddleware,
_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.wsgitestutil import WsgiTestBase
@ -49,6 +58,16 @@ def expected_attributes(override_attributes):
return default_attributes
_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 TestProgrammatic(InstrumentationTest, WsgiTestBase):
def setUp(self):
super().setUp()
@ -250,6 +269,106 @@ class TestProgrammatic(InstrumentationTest, WsgiTestBase):
span_list = self.memory_exporter.get_finished_spans()
self.assertEqual(len(span_list), 1)
def test_flask_metrics(self):
start = default_timer()
self.client.get("/hello/123")
self.client.get("/hello/321")
self.client.get("/hello/756")
duration = max(round((default_timer() - start) * 1000), 0)
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)
self.assertAlmostEqual(
duration, point.sum, delta=10
)
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_flask_metric_values(self):
start = default_timer()
self.client.post("/hello/756")
self.client.post("/hello/756")
self.client.post("/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.assertEqual(point.count, 3)
self.assertAlmostEqual(
duration, point.sum, delta=10
)
if isinstance(point, NumberDataPoint):
self.assertEqual(point.value, 0)
def test_basic_metric_success(self):
self.client.get("/hello/756")
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 resource_metric in metrics_list.resource_metrics:
for scope_metrics in resource_metric.scope_metrics:
for metric in scope_metrics.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)
elif isinstance(point, NumberDataPoint):
self.assertDictEqual(
expected_requests_count_attributes,
dict(point.attributes),
)
self.assertEqual(point.value, 0)
def test_metric_uninstrument(self):
self.client.delete("/hello/756")
FlaskInstrumentor().uninstrument_app(self.app)
self.client.delete("/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 TestProgrammaticHooks(InstrumentationTest, WsgiTestBase):
def setUp(self):

View File

@ -334,6 +334,22 @@ def _parse_status_code(resp_status):
return None
def _parse_active_request_count_attrs(req_attrs):
active_requests_count_attrs = {}
for attr_key in _active_requests_count_attrs:
if req_attrs.get(attr_key) is not None:
active_requests_count_attrs[attr_key] = req_attrs[attr_key]
return active_requests_count_attrs
def _parse_duration_attrs(req_attrs):
duration_attrs = {}
for attr_key in _duration_attrs:
if req_attrs.get(attr_key) is not None:
duration_attrs[attr_key] = req_attrs[attr_key]
return duration_attrs
def add_response_attributes(
span, start_response_status, response_headers
): # pylint: disable=unused-argument
@ -436,15 +452,10 @@ class OpenTelemetryMiddleware:
start_response: The WSGI start_response callable.
"""
req_attrs = collect_request_attributes(environ)
active_requests_count_attrs = {}
for attr_key in _active_requests_count_attrs:
if req_attrs.get(attr_key) is not None:
active_requests_count_attrs[attr_key] = req_attrs[attr_key]
duration_attrs = {}
for attr_key in _duration_attrs:
if req_attrs.get(attr_key) is not None:
duration_attrs[attr_key] = req_attrs[attr_key]
active_requests_count_attrs = _parse_active_request_count_attrs(
req_attrs
)
duration_attrs = _parse_duration_attrs(req_attrs)
span, token = _start_internal_or_server_span(
tracer=self.tracer,