Skip to content

Commit da8b8d9

Browse files
Oberon00c24t
authored andcommitted
Add Flask integration based on WSGI ext (open-telemetry#206)
The flask integration has (only) two advantages over the plain WSGI middleware approach: - It can use the endpoint as span name (which is lower cardinality than the route; cf open-telemetry#270) - It can set the http.route attribute. In addition, it also has an easier syntax to enable (you don't have to know about Flask.wsgi_app).
1 parent 077a08e commit da8b8d9

File tree

18 files changed

+603
-135
lines changed

18 files changed

+603
-135
lines changed

examples/opentelemetry-example-app/setup.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@
3838
"opentelemetry-api",
3939
"opentelemetry-sdk",
4040
"opentelemetry-ext-http-requests",
41-
"opentelemetry-ext-wsgi",
41+
"opentelemetry-ext-flask",
4242
"flask",
4343
"requests",
4444
],

examples/opentelemetry-example-app/src/opentelemetry_example_app/flask_example.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121

2222
import opentelemetry.ext.http_requests
2323
from opentelemetry import propagators, trace
24-
from opentelemetry.ext.wsgi import OpenTelemetryMiddleware
24+
from opentelemetry.ext.flask import instrument_app
2525
from opentelemetry.sdk.context.propagation.b3_format import B3Format
2626
from opentelemetry.sdk.trace import Tracer
2727

@@ -57,7 +57,7 @@ def configure_opentelemetry(flask_app: flask.Flask):
5757
# and the frameworks and libraries that are used together, automatically
5858
# creating Spans and propagating context as appropriate.
5959
opentelemetry.ext.http_requests.enable(trace.tracer())
60-
flask_app.wsgi_app = OpenTelemetryMiddleware(flask_app.wsgi_app)
60+
instrument_app(flask_app)
6161

6262

6363
app = flask.Flask(__name__)
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
OpenTelemetry Flask tracing
2+
===========================
3+
4+
This library builds on the OpenTelemetry WSGI middleware to track web requests
5+
in Flask applications. In addition to opentelemetry-ext-wsgi, it supports
6+
flask-specific features such as:
7+
8+
* The Flask endpoint name is used as the Span name.
9+
* The ``http.route`` Span attribute is set so that one can see which URL rule
10+
matched a request.
11+
12+
Usage
13+
-----
14+
15+
.. code-block:: python
16+
17+
from flask import Flask
18+
from opentelemetry.ext.flask import instrument_app
19+
20+
app = Flask(__name__)
21+
instrument_app(app) # This is where the magic happens. ✨
22+
23+
@app.route("/")
24+
def hello():
25+
return "Hello!"
26+
27+
if __name__ == "__main__":
28+
app.run(debug=True)
29+
30+
31+
References
32+
----------
33+
34+
* `OpenTelemetry Project <https://opentelemetry.io/>`_
35+
* `OpenTelemetry WSGI extension <https://github.com/open-telemetry/opentelemetry-python/tree/master/ext/opentelemetry-ext-wsgi>`_

ext/opentelemetry-ext-flask/setup.cfg

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
# Copyright 2019, OpenTelemetry Authors
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
#
15+
[metadata]
16+
name = opentelemetry-ext-flask
17+
description = Flask tracing for OpenTelemetry (based on opentelemetry-ext-wsgi)
18+
long_description = file: README.rst
19+
long_description_content_type = text/x-rst
20+
author = OpenTelemetry Authors
21+
author_email = cncf-opentelemetry-contributors@lists.cncf.io
22+
url = https://github.com/open-telemetry/opentelemetry-python/ext/opentelemetry-ext-flask
23+
platforms = any
24+
license = Apache-2.0
25+
classifiers =
26+
Development Status :: 3 - Alpha
27+
Intended Audience :: Developers
28+
License :: OSI Approved :: Apache Software License
29+
Programming Language :: Python
30+
Programming Language :: Python :: 3
31+
Programming Language :: Python :: 3.4
32+
Programming Language :: Python :: 3.5
33+
Programming Language :: Python :: 3.6
34+
Programming Language :: Python :: 3.7
35+
36+
[options]
37+
python_requires = >=3.4
38+
package_dir=
39+
=src
40+
packages=find_namespace:
41+
install_requires =
42+
opentelemetry-ext-wsgi
43+
44+
[options.extras_require]
45+
test =
46+
flask~=1.0
47+
opentelemetry-ext-testutil
48+
49+
[options.packages.find]
50+
where = src

ext/opentelemetry-ext-flask/setup.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
# Copyright 2019, OpenTelemetry Authors
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
import os
15+
16+
import setuptools
17+
18+
BASE_DIR = os.path.dirname(__file__)
19+
VERSION_FILENAME = os.path.join(
20+
BASE_DIR, "src", "opentelemetry", "ext", "flask", "version.py"
21+
)
22+
PACKAGE_INFO = {}
23+
with open(VERSION_FILENAME) as f:
24+
exec(f.read(), PACKAGE_INFO)
25+
26+
setuptools.setup(version=PACKAGE_INFO["__version__"])
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
# Note: This package is not named "flask" because of
2+
# https://github.com/PyCQA/pylint/issues/2648
3+
4+
import logging
5+
6+
from flask import request as flask_request
7+
8+
import opentelemetry.ext.wsgi as otel_wsgi
9+
from opentelemetry import propagators, trace
10+
from opentelemetry.util import time_ns
11+
12+
logger = logging.getLogger(__name__)
13+
14+
_ENVIRON_STARTTIME_KEY = object()
15+
_ENVIRON_SPAN_KEY = object()
16+
_ENVIRON_ACTIVATION_KEY = object()
17+
18+
19+
def instrument_app(flask):
20+
"""Makes the passed-in Flask object traced by OpenTelemetry.
21+
22+
You must not call this function multiple times on the same Flask object.
23+
"""
24+
25+
wsgi = flask.wsgi_app
26+
27+
def wrapped_app(environ, start_response):
28+
# We want to measure the time for route matching, etc.
29+
# In theory, we could start the span here and use update_name later
30+
# but that API is "highly discouraged" so we better avoid it.
31+
environ[_ENVIRON_STARTTIME_KEY] = time_ns()
32+
33+
def _start_response(status, response_headers, *args, **kwargs):
34+
span = flask_request.environ.get(_ENVIRON_SPAN_KEY)
35+
if span:
36+
otel_wsgi.add_response_attributes(
37+
span, status, response_headers
38+
)
39+
else:
40+
logger.warning(
41+
"Flask environ's OpenTelemetry span missing at _start_response(%s)",
42+
status,
43+
)
44+
return start_response(status, response_headers, *args, **kwargs)
45+
46+
return wsgi(environ, _start_response)
47+
48+
flask.wsgi_app = wrapped_app
49+
50+
flask.before_request(_before_flask_request)
51+
flask.teardown_request(_teardown_flask_request)
52+
53+
54+
def _before_flask_request():
55+
environ = flask_request.environ
56+
span_name = flask_request.endpoint or otel_wsgi.get_default_span_name(
57+
environ
58+
)
59+
parent_span = propagators.extract(
60+
otel_wsgi.get_header_from_environ, environ
61+
)
62+
63+
tracer = trace.tracer()
64+
65+
span = tracer.create_span(
66+
span_name, parent_span, kind=trace.SpanKind.SERVER
67+
)
68+
span.start(environ.get(_ENVIRON_STARTTIME_KEY))
69+
activation = tracer.use_span(span, end_on_exit=True)
70+
activation.__enter__()
71+
environ[_ENVIRON_ACTIVATION_KEY] = activation
72+
environ[_ENVIRON_SPAN_KEY] = span
73+
otel_wsgi.add_request_attributes(span, environ)
74+
if flask_request.url_rule:
75+
# For 404 that result from no route found, etc, we don't have a url_rule.
76+
span.set_attribute("http.route", flask_request.url_rule.rule)
77+
78+
79+
def _teardown_flask_request(exc):
80+
activation = flask_request.environ.get(_ENVIRON_ACTIVATION_KEY)
81+
if not activation:
82+
logger.warning(
83+
"Flask environ's OpenTelemetry activation missing at _teardown_flask_request(%s)",
84+
exc,
85+
)
86+
return
87+
88+
if exc is None:
89+
activation.__exit__(None, None, None)
90+
else:
91+
activation.__exit__(
92+
type(exc), exc, getattr(exc, "__traceback__", None)
93+
)
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
# Copyright 2019, OpenTelemetry Authors
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
__version__ = "0.3dev0"

ext/opentelemetry-ext-flask/tests/__init__.py

Whitespace-only changes.
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
# Copyright 2019, OpenTelemetry Authors
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
import unittest
16+
17+
from flask import Flask
18+
from werkzeug.test import Client
19+
from werkzeug.wrappers import BaseResponse
20+
21+
import opentelemetry.ext.flask as otel_flask
22+
from opentelemetry import trace as trace_api
23+
from opentelemetry.ext.testutil.wsgitestutil import WsgiTestBase
24+
25+
26+
class TestFlaskIntegration(WsgiTestBase):
27+
def setUp(self):
28+
super().setUp()
29+
30+
self.span_attrs = {}
31+
32+
def setspanattr(key, value):
33+
self.assertIsInstance(key, str)
34+
self.span_attrs[key] = value
35+
36+
self.span.set_attribute = setspanattr
37+
38+
self.app = Flask(__name__)
39+
40+
def hello_endpoint(helloid):
41+
if helloid == 500:
42+
raise ValueError(":-(")
43+
return "Hello: " + str(helloid)
44+
45+
self.app.route("/hello/<int:helloid>")(hello_endpoint)
46+
47+
otel_flask.instrument_app(self.app)
48+
self.client = Client(self.app, BaseResponse)
49+
50+
def test_simple(self):
51+
resp = self.client.get("/hello/123")
52+
self.assertEqual(200, resp.status_code)
53+
self.assertEqual([b"Hello: 123"], list(resp.response))
54+
55+
self.create_span.assert_called_with(
56+
"hello_endpoint",
57+
trace_api.INVALID_SPAN_CONTEXT,
58+
kind=trace_api.SpanKind.SERVER,
59+
)
60+
self.assertEqual(1, self.span.start.call_count)
61+
62+
# TODO: Change this test to use the SDK, as mocking becomes painful
63+
64+
self.assertEqual(
65+
self.span_attrs,
66+
{
67+
"component": "http",
68+
"http.method": "GET",
69+
"http.host": "localhost",
70+
"http.url": "http://localhost/hello/123",
71+
"http.route": "/hello/<int:helloid>",
72+
"http.status_code": 200,
73+
"http.status_text": "OK",
74+
},
75+
)
76+
77+
def test_404(self):
78+
resp = self.client.post("/bye")
79+
self.assertEqual(404, resp.status_code)
80+
resp.close()
81+
82+
self.create_span.assert_called_with(
83+
"/bye",
84+
trace_api.INVALID_SPAN_CONTEXT,
85+
kind=trace_api.SpanKind.SERVER,
86+
)
87+
self.assertEqual(1, self.span.start.call_count)
88+
89+
# Nope, this uses Tracer.use_span(end_on_exit)
90+
# self.assertEqual(1, self.span.end.call_count)
91+
# TODO: Change this test to use the SDK, as mocking becomes painful
92+
93+
self.assertEqual(
94+
self.span_attrs,
95+
{
96+
"component": "http",
97+
"http.method": "POST",
98+
"http.host": "localhost",
99+
"http.url": "http://localhost/bye",
100+
"http.status_code": 404,
101+
"http.status_text": "NOT FOUND",
102+
},
103+
)
104+
105+
def test_internal_error(self):
106+
resp = self.client.get("/hello/500")
107+
self.assertEqual(500, resp.status_code)
108+
resp.close()
109+
110+
self.create_span.assert_called_with(
111+
"hello_endpoint",
112+
trace_api.INVALID_SPAN_CONTEXT,
113+
kind=trace_api.SpanKind.SERVER,
114+
)
115+
self.assertEqual(1, self.span.start.call_count)
116+
117+
# Nope, this uses Tracer.use_span(end_on_exit)
118+
# self.assertEqual(1, self.span.end.call_count)
119+
# TODO: Change this test to use the SDK, as mocking becomes painful
120+
121+
self.assertEqual(
122+
self.span_attrs,
123+
{
124+
"component": "http",
125+
"http.method": "GET",
126+
"http.host": "localhost",
127+
"http.url": "http://localhost/hello/500",
128+
"http.route": "/hello/<int:helloid>",
129+
"http.status_code": 500,
130+
"http.status_text": "INTERNAL SERVER ERROR",
131+
},
132+
)
133+
134+
135+
if __name__ == "__main__":
136+
unittest.main()

0 commit comments

Comments
 (0)