Skip to content

Commit ebe6d18

Browse files
authoredAug 5, 2022
Metrics instrumentation flask (#1186)
1 parent 14077a9 commit ebe6d18

File tree

6 files changed

+214
-18
lines changed

6 files changed

+214
-18
lines changed
 

‎CHANGELOG.md

+2
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1919
- `opentelemetry-instrumentation-redis` add support to instrument RedisCluster clients
2020
([#1177](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/1177))
2121
- `opentelemetry-instrumentation-sqlalchemy` Added span for the connection phase ([#1133](https://github.com/open-telemetry/opentelemetry-python-contrib/issues/1133))
22+
- Add metric instumentation for flask
23+
([#1186](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/1186))
2224

2325
## [1.12.0rc2-0.32b0](https://github.com/open-telemetry/opentelemetry-python/releases/tag/v1.12.0rc2-0.32b0) - 2022-07-01
2426

‎instrumentation/README.md

+1-1
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
| [opentelemetry-instrumentation-elasticsearch](./opentelemetry-instrumentation-elasticsearch) | elasticsearch >= 2.0 | No
1818
| [opentelemetry-instrumentation-falcon](./opentelemetry-instrumentation-falcon) | falcon >= 1.4.1, < 4.0.0 | No
1919
| [opentelemetry-instrumentation-fastapi](./opentelemetry-instrumentation-fastapi) | fastapi ~= 0.58 | No
20-
| [opentelemetry-instrumentation-flask](./opentelemetry-instrumentation-flask) | flask >= 1.0, < 3.0 | No
20+
| [opentelemetry-instrumentation-flask](./opentelemetry-instrumentation-flask) | flask >= 1.0, < 3.0 | Yes
2121
| [opentelemetry-instrumentation-grpc](./opentelemetry-instrumentation-grpc) | grpcio ~= 1.27 | No
2222
| [opentelemetry-instrumentation-httpx](./opentelemetry-instrumentation-httpx) | httpx >= 0.18.0 | No
2323
| [opentelemetry-instrumentation-jinja2](./opentelemetry-instrumentation-jinja2) | jinja2 >= 2.7, < 4.0 | No

‎instrumentation/opentelemetry-instrumentation-flask/src/opentelemetry/instrumentation/flask/__init__.py

+69-7
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,7 @@ def response_hook(span: Span, status: str, response_headers: List):
141141
"""
142142

143143
from logging import getLogger
144+
from timeit import default_timer
144145
from typing import Collection
145146

146147
import flask
@@ -154,6 +155,7 @@ def response_hook(span: Span, status: str, response_headers: List):
154155
get_global_response_propagator,
155156
)
156157
from opentelemetry.instrumentation.utils import _start_internal_or_server_span
158+
from opentelemetry.metrics import get_meter
157159
from opentelemetry.semconv.trace import SpanAttributes
158160
from opentelemetry.util._time import _time_ns
159161
from opentelemetry.util.http import get_excluded_urls, parse_excluded_urls
@@ -165,7 +167,6 @@ def response_hook(span: Span, status: str, response_headers: List):
165167
_ENVIRON_ACTIVATION_KEY = "opentelemetry-flask.activation_key"
166168
_ENVIRON_TOKEN = "opentelemetry-flask.token"
167169

168-
169170
_excluded_urls_from_env = get_excluded_urls("FLASK")
170171

171172

@@ -178,13 +179,26 @@ def get_default_span_name():
178179
return span_name
179180

180181

181-
def _rewrapped_app(wsgi_app, response_hook=None, excluded_urls=None):
182+
def _rewrapped_app(
183+
wsgi_app,
184+
active_requests_counter,
185+
duration_histogram,
186+
response_hook=None,
187+
excluded_urls=None,
188+
):
182189
def _wrapped_app(wrapped_app_environ, start_response):
183190
# We want to measure the time for route matching, etc.
184191
# In theory, we could start the span here and use
185192
# update_name later but that API is "highly discouraged" so
186193
# we better avoid it.
187194
wrapped_app_environ[_ENVIRON_STARTTIME_KEY] = _time_ns()
195+
start = default_timer()
196+
attributes = otel_wsgi.collect_request_attributes(wrapped_app_environ)
197+
active_requests_count_attrs = (
198+
otel_wsgi._parse_active_request_count_attrs(attributes)
199+
)
200+
duration_attrs = otel_wsgi._parse_duration_attrs(attributes)
201+
active_requests_counter.add(1, active_requests_count_attrs)
188202

189203
def _start_response(status, response_headers, *args, **kwargs):
190204
if flask.request and (
@@ -204,6 +218,11 @@ def _start_response(status, response_headers, *args, **kwargs):
204218
otel_wsgi.add_response_attributes(
205219
span, status, response_headers
206220
)
221+
status_code = otel_wsgi._parse_status_code(status)
222+
if status_code is not None:
223+
duration_attrs[
224+
SpanAttributes.HTTP_STATUS_CODE
225+
] = status_code
207226
if (
208227
span.is_recording()
209228
and span.kind == trace.SpanKind.SERVER
@@ -223,13 +242,19 @@ def _start_response(status, response_headers, *args, **kwargs):
223242
response_hook(span, status, response_headers)
224243
return start_response(status, response_headers, *args, **kwargs)
225244

226-
return wsgi_app(wrapped_app_environ, _start_response)
245+
result = wsgi_app(wrapped_app_environ, _start_response)
246+
duration = max(round((default_timer() - start) * 1000), 0)
247+
duration_histogram.record(duration, duration_attrs)
248+
active_requests_counter.add(-1, active_requests_count_attrs)
249+
return result
227250

228251
return _wrapped_app
229252

230253

231254
def _wrapped_before_request(
232-
request_hook=None, tracer=None, excluded_urls=None
255+
request_hook=None,
256+
tracer=None,
257+
excluded_urls=None,
233258
):
234259
def _before_request():
235260
if excluded_urls and excluded_urls.url_disabled(flask.request.url):
@@ -278,7 +303,9 @@ def _before_request():
278303
return _before_request
279304

280305

281-
def _wrapped_teardown_request(excluded_urls=None):
306+
def _wrapped_teardown_request(
307+
excluded_urls=None,
308+
):
282309
def _teardown_request(exc):
283310
# pylint: disable=E1101
284311
if excluded_urls and excluded_urls.url_disabled(flask.request.url):
@@ -290,7 +317,6 @@ def _teardown_request(exc):
290317
# a way that doesn't run `before_request`, like when it is created
291318
# with `app.test_request_context`.
292319
return
293-
294320
if exc is None:
295321
activation.__exit__(None, None, None)
296322
else:
@@ -310,15 +336,32 @@ class _InstrumentedFlask(flask.Flask):
310336
_tracer_provider = None
311337
_request_hook = None
312338
_response_hook = None
339+
_meter_provider = None
313340

314341
def __init__(self, *args, **kwargs):
315342
super().__init__(*args, **kwargs)
316343

317344
self._original_wsgi_app = self.wsgi_app
318345
self._is_instrumented_by_opentelemetry = True
319346

347+
meter = get_meter(
348+
__name__, __version__, _InstrumentedFlask._meter_provider
349+
)
350+
duration_histogram = meter.create_histogram(
351+
name="http.server.duration",
352+
unit="ms",
353+
description="measures the duration of the inbound HTTP request",
354+
)
355+
active_requests_counter = meter.create_up_down_counter(
356+
name="http.server.active_requests",
357+
unit="requests",
358+
description="measures the number of concurrent HTTP requests that are currently in-flight",
359+
)
360+
320361
self.wsgi_app = _rewrapped_app(
321362
self.wsgi_app,
363+
active_requests_counter,
364+
duration_histogram,
322365
_InstrumentedFlask._response_hook,
323366
excluded_urls=_InstrumentedFlask._excluded_urls,
324367
)
@@ -367,6 +410,8 @@ def _instrument(self, **kwargs):
367410
if excluded_urls is None
368411
else parse_excluded_urls(excluded_urls)
369412
)
413+
meter_provider = kwargs.get("meter_provider")
414+
_InstrumentedFlask._meter_provider = meter_provider
370415
flask.Flask = _InstrumentedFlask
371416

372417
def _uninstrument(self, **kwargs):
@@ -379,6 +424,7 @@ def instrument_app(
379424
response_hook=None,
380425
tracer_provider=None,
381426
excluded_urls=None,
427+
meter_provider=None,
382428
):
383429
if not hasattr(app, "_is_instrumented_by_opentelemetry"):
384430
app._is_instrumented_by_opentelemetry = False
@@ -389,9 +435,25 @@ def instrument_app(
389435
if excluded_urls is not None
390436
else _excluded_urls_from_env
391437
)
438+
meter = get_meter(__name__, __version__, meter_provider)
439+
duration_histogram = meter.create_histogram(
440+
name="http.server.duration",
441+
unit="ms",
442+
description="measures the duration of the inbound HTTP request",
443+
)
444+
active_requests_counter = meter.create_up_down_counter(
445+
name="http.server.active_requests",
446+
unit="requests",
447+
description="measures the number of concurrent HTTP requests that are currently in-flight",
448+
)
449+
392450
app._original_wsgi_app = app.wsgi_app
393451
app.wsgi_app = _rewrapped_app(
394-
app.wsgi_app, response_hook, excluded_urls=excluded_urls
452+
app.wsgi_app,
453+
active_requests_counter,
454+
duration_histogram,
455+
response_hook,
456+
excluded_urls=excluded_urls,
395457
)
396458

397459
tracer = trace.get_tracer(__name__, __version__, tracer_provider)

‎instrumentation/opentelemetry-instrumentation-flask/src/opentelemetry/instrumentation/flask/package.py

+2
Original file line numberDiff line numberDiff line change
@@ -14,3 +14,5 @@
1414

1515

1616
_instruments = ("flask >= 1.0, < 3.0",)
17+
18+
_supports_metrics = True

‎instrumentation/opentelemetry-instrumentation-flask/tests/test_programmatic.py

+120-1
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
# See the License for the specific language governing permissions and
1313
# limitations under the License.
1414

15+
from timeit import default_timer
1516
from unittest.mock import Mock, patch
1617

1718
from flask import Flask, request
@@ -23,7 +24,15 @@
2324
get_global_response_propagator,
2425
set_global_response_propagator,
2526
)
26-
from opentelemetry.instrumentation.wsgi import OpenTelemetryMiddleware
27+
from opentelemetry.instrumentation.wsgi import (
28+
OpenTelemetryMiddleware,
29+
_active_requests_count_attrs,
30+
_duration_attrs,
31+
)
32+
from opentelemetry.sdk.metrics.export import (
33+
HistogramDataPoint,
34+
NumberDataPoint,
35+
)
2736
from opentelemetry.sdk.resources import Resource
2837
from opentelemetry.semconv.trace import SpanAttributes
2938
from opentelemetry.test.wsgitestutil import WsgiTestBase
@@ -49,6 +58,16 @@ def expected_attributes(override_attributes):
4958
return default_attributes
5059

5160

61+
_expected_metric_names = [
62+
"http.server.active_requests",
63+
"http.server.duration",
64+
]
65+
_recommended_attrs = {
66+
"http.server.active_requests": _active_requests_count_attrs,
67+
"http.server.duration": _duration_attrs,
68+
}
69+
70+
5271
class TestProgrammatic(InstrumentationTest, WsgiTestBase):
5372
def setUp(self):
5473
super().setUp()
@@ -250,6 +269,106 @@ def test_exclude_lists_from_explicit(self):
250269
span_list = self.memory_exporter.get_finished_spans()
251270
self.assertEqual(len(span_list), 1)
252271

272+
def test_flask_metrics(self):
273+
start = default_timer()
274+
self.client.get("/hello/123")
275+
self.client.get("/hello/321")
276+
self.client.get("/hello/756")
277+
duration = max(round((default_timer() - start) * 1000), 0)
278+
metrics_list = self.memory_metrics_reader.get_metrics_data()
279+
number_data_point_seen = False
280+
histogram_data_point_seen = False
281+
self.assertTrue(len(metrics_list.resource_metrics) != 0)
282+
for resource_metric in metrics_list.resource_metrics:
283+
self.assertTrue(len(resource_metric.scope_metrics) != 0)
284+
for scope_metric in resource_metric.scope_metrics:
285+
self.assertTrue(len(scope_metric.metrics) != 0)
286+
for metric in scope_metric.metrics:
287+
self.assertIn(metric.name, _expected_metric_names)
288+
data_points = list(metric.data.data_points)
289+
self.assertEqual(len(data_points), 1)
290+
for point in data_points:
291+
if isinstance(point, HistogramDataPoint):
292+
self.assertEqual(point.count, 3)
293+
self.assertAlmostEqual(
294+
duration, point.sum, delta=10
295+
)
296+
histogram_data_point_seen = True
297+
if isinstance(point, NumberDataPoint):
298+
number_data_point_seen = True
299+
for attr in point.attributes:
300+
self.assertIn(
301+
attr, _recommended_attrs[metric.name]
302+
)
303+
self.assertTrue(number_data_point_seen and histogram_data_point_seen)
304+
305+
def test_flask_metric_values(self):
306+
start = default_timer()
307+
self.client.post("/hello/756")
308+
self.client.post("/hello/756")
309+
self.client.post("/hello/756")
310+
duration = max(round((default_timer() - start) * 1000), 0)
311+
metrics_list = self.memory_metrics_reader.get_metrics_data()
312+
for resource_metric in metrics_list.resource_metrics:
313+
for scope_metric in resource_metric.scope_metrics:
314+
for metric in scope_metric.metrics:
315+
for point in list(metric.data.data_points):
316+
if isinstance(point, HistogramDataPoint):
317+
self.assertEqual(point.count, 3)
318+
self.assertAlmostEqual(
319+
duration, point.sum, delta=10
320+
)
321+
if isinstance(point, NumberDataPoint):
322+
self.assertEqual(point.value, 0)
323+
324+
def test_basic_metric_success(self):
325+
self.client.get("/hello/756")
326+
expected_duration_attributes = {
327+
"http.method": "GET",
328+
"http.host": "localhost",
329+
"http.scheme": "http",
330+
"http.flavor": "1.1",
331+
"http.server_name": "localhost",
332+
"net.host.port": 80,
333+
"http.status_code": 200,
334+
}
335+
expected_requests_count_attributes = {
336+
"http.method": "GET",
337+
"http.host": "localhost",
338+
"http.scheme": "http",
339+
"http.flavor": "1.1",
340+
"http.server_name": "localhost",
341+
}
342+
metrics_list = self.memory_metrics_reader.get_metrics_data()
343+
for resource_metric in metrics_list.resource_metrics:
344+
for scope_metrics in resource_metric.scope_metrics:
345+
for metric in scope_metrics.metrics:
346+
for point in list(metric.data.data_points):
347+
if isinstance(point, HistogramDataPoint):
348+
self.assertDictEqual(
349+
expected_duration_attributes,
350+
dict(point.attributes),
351+
)
352+
self.assertEqual(point.count, 1)
353+
elif isinstance(point, NumberDataPoint):
354+
self.assertDictEqual(
355+
expected_requests_count_attributes,
356+
dict(point.attributes),
357+
)
358+
self.assertEqual(point.value, 0)
359+
360+
def test_metric_uninstrument(self):
361+
self.client.delete("/hello/756")
362+
FlaskInstrumentor().uninstrument_app(self.app)
363+
self.client.delete("/hello/756")
364+
metrics_list = self.memory_metrics_reader.get_metrics_data()
365+
for resource_metric in metrics_list.resource_metrics:
366+
for scope_metric in resource_metric.scope_metrics:
367+
for metric in scope_metric.metrics:
368+
for point in list(metric.data.data_points):
369+
if isinstance(point, HistogramDataPoint):
370+
self.assertEqual(point.count, 1)
371+
253372

254373
class TestProgrammaticHooks(InstrumentationTest, WsgiTestBase):
255374
def setUp(self):

‎instrumentation/opentelemetry-instrumentation-wsgi/src/opentelemetry/instrumentation/wsgi/__init__.py

+20-9
Original file line numberDiff line numberDiff line change
@@ -334,6 +334,22 @@ def _parse_status_code(resp_status):
334334
return None
335335

336336

337+
def _parse_active_request_count_attrs(req_attrs):
338+
active_requests_count_attrs = {}
339+
for attr_key in _active_requests_count_attrs:
340+
if req_attrs.get(attr_key) is not None:
341+
active_requests_count_attrs[attr_key] = req_attrs[attr_key]
342+
return active_requests_count_attrs
343+
344+
345+
def _parse_duration_attrs(req_attrs):
346+
duration_attrs = {}
347+
for attr_key in _duration_attrs:
348+
if req_attrs.get(attr_key) is not None:
349+
duration_attrs[attr_key] = req_attrs[attr_key]
350+
return duration_attrs
351+
352+
337353
def add_response_attributes(
338354
span, start_response_status, response_headers
339355
): # pylint: disable=unused-argument
@@ -436,15 +452,10 @@ def __call__(self, environ, start_response):
436452
start_response: The WSGI start_response callable.
437453
"""
438454
req_attrs = collect_request_attributes(environ)
439-
active_requests_count_attrs = {}
440-
for attr_key in _active_requests_count_attrs:
441-
if req_attrs.get(attr_key) is not None:
442-
active_requests_count_attrs[attr_key] = req_attrs[attr_key]
443-
444-
duration_attrs = {}
445-
for attr_key in _duration_attrs:
446-
if req_attrs.get(attr_key) is not None:
447-
duration_attrs[attr_key] = req_attrs[attr_key]
455+
active_requests_count_attrs = _parse_active_request_count_attrs(
456+
req_attrs
457+
)
458+
duration_attrs = _parse_duration_attrs(req_attrs)
448459

449460
span, token = _start_internal_or_server_span(
450461
tracer=self.tracer,

0 commit comments

Comments
 (0)
Please sign in to comment.