-
Notifications
You must be signed in to change notification settings - Fork 651
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Add Flask integration based on WSGI ext #206
Changes from 15 commits
86d10e4
d218493
bb9bc8f
4fbb01f
798315c
5c242c4
772d999
4715aa6
ad2ac6c
c3d4d98
6daa087
2357136
636ffdf
2f7938c
1a8ee17
35c6595
b20aea5
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,35 @@ | ||
OpenTelemetry Flask tracing | ||
=========================== | ||
|
||
This library builds on the OpenTelemetry WSGI middleware to track web requests | ||
in Flask applications. In addition to opentelemetry-ext-wsgi, it supports | ||
flask-specific features such as: | ||
|
||
* The Flask endpoint name is used as the Span name. | ||
* The ``http.route`` Span attribute is set so that one can see which URL rule | ||
matched a request. | ||
|
||
Usage | ||
----- | ||
|
||
.. code-block:: python | ||
|
||
from flask import Flask | ||
from opentelemetry.ext.flask import instrument_app | ||
|
||
app = Flask(__name__) | ||
instrument_app(app) # This is where the magic happens. ✨ | ||
|
||
@app.route("/") | ||
def hello(): | ||
return "Hello!" | ||
|
||
if __name__ == "__main__": | ||
app.run(debug=True) | ||
|
||
|
||
References | ||
---------- | ||
|
||
* `OpenTelemetry Project <https://opentelemetry.io/>`_ | ||
* `OpenTelemetry WSGI extension <https://github.com/open-telemetry/opentelemetry-python/tree/master/ext/opentelemetry-ext-wsgi>`_ |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,50 @@ | ||
# Copyright 2019, OpenTelemetry Authors | ||
# | ||
# Licensed under the Apache License, Version 2.0 (the "License"); | ||
# you may not use this file except in compliance with the License. | ||
# You may obtain a copy of the License at | ||
# | ||
# http://www.apache.org/licenses/LICENSE-2.0 | ||
# | ||
# Unless required by applicable law or agreed to in writing, software | ||
# distributed under the License is distributed on an "AS IS" BASIS, | ||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
# See the License for the specific language governing permissions and | ||
# limitations under the License. | ||
# | ||
[metadata] | ||
name = opentelemetry-ext-flask | ||
description = Flask tracing for OpenTelemetry (based on opentelemetry-ext-wsgi) | ||
long_description = file: README.rst | ||
long_description_content_type = text/x-rst | ||
author = OpenTelemetry Authors | ||
author_email = [email protected] | ||
url = https://github.com/open-telemetry/opentelemetry-python/ext/opentelemetry-ext-flask | ||
platforms = any | ||
license = Apache-2.0 | ||
classifiers = | ||
Development Status :: 3 - Alpha | ||
Intended Audience :: Developers | ||
License :: OSI Approved :: Apache Software License | ||
Programming Language :: Python | ||
Programming Language :: Python :: 3 | ||
Programming Language :: Python :: 3.4 | ||
Programming Language :: Python :: 3.5 | ||
Programming Language :: Python :: 3.6 | ||
Programming Language :: Python :: 3.7 | ||
|
||
[options] | ||
python_requires = >=3.4 | ||
package_dir= | ||
=src | ||
packages=find_namespace: | ||
install_requires = | ||
opentelemetry-ext-wsgi | ||
|
||
[options.extras_require] | ||
test = | ||
flask~=1.0 | ||
opentelemetry-ext-testutil | ||
|
||
[options.packages.find] | ||
where = src |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,26 @@ | ||
# Copyright 2019, OpenTelemetry Authors | ||
# | ||
# Licensed under the Apache License, Version 2.0 (the "License"); | ||
# you may not use this file except in compliance with the License. | ||
# You may obtain a copy of the License at | ||
# | ||
# http://www.apache.org/licenses/LICENSE-2.0 | ||
# | ||
# Unless required by applicable law or agreed to in writing, software | ||
# distributed under the License is distributed on an "AS IS" BASIS, | ||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
# See the License for the specific language governing permissions and | ||
# limitations under the License. | ||
import os | ||
|
||
import setuptools | ||
|
||
BASE_DIR = os.path.dirname(__file__) | ||
VERSION_FILENAME = os.path.join( | ||
BASE_DIR, "src", "opentelemetry", "ext", "flask", "version.py" | ||
) | ||
PACKAGE_INFO = {} | ||
with open(VERSION_FILENAME) as f: | ||
exec(f.read(), PACKAGE_INFO) | ||
|
||
setuptools.setup(version=PACKAGE_INFO["__version__"]) |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,93 @@ | ||
# Note: This package is not named "flask" because of | ||
# https://github.com/PyCQA/pylint/issues/2648 | ||
|
||
import logging | ||
|
||
from flask import request as flask_request | ||
|
||
import opentelemetry.ext.wsgi as otel_wsgi | ||
from opentelemetry import propagators, trace | ||
from opentelemetry.util import time_ns | ||
|
||
logger = logging.getLogger(__name__) | ||
|
||
_ENVIRON_STARTTIME_KEY = object() | ||
_ENVIRON_SPAN_KEY = object() | ||
_ENVIRON_ACTIVATION_KEY = object() | ||
|
||
|
||
def instrument_app(flask): | ||
"""Makes the passed-in Flask object traced by OpenTelemetry. | ||
|
||
You must not call this function multiple times on the same Flask object. | ||
""" | ||
|
||
wsgi = flask.wsgi_app | ||
|
||
def 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. | ||
environ[_ENVIRON_STARTTIME_KEY] = time_ns() | ||
|
||
def _start_response(status, response_headers, *args, **kwargs): | ||
span = flask_request.environ.get(_ENVIRON_SPAN_KEY) | ||
if span: | ||
otel_wsgi.add_response_attributes( | ||
span, status, response_headers | ||
) | ||
else: | ||
logger.warning( | ||
"Flask environ's OpenTelemetry span missing at _start_response(%s)", | ||
status, | ||
) | ||
return start_response(status, response_headers, *args, **kwargs) | ||
|
||
return wsgi(environ, _start_response) | ||
|
||
flask.wsgi_app = wrapped_app | ||
|
||
flask.before_request(_before_flask_request) | ||
flask.teardown_request(_teardown_flask_request) | ||
|
||
|
||
def _before_flask_request(): | ||
environ = flask_request.environ | ||
span_name = flask_request.endpoint or otel_wsgi.get_default_span_name( | ||
environ | ||
) | ||
parent_span = propagators.extract( | ||
otel_wsgi.get_header_from_environ, environ | ||
) | ||
|
||
tracer = trace.tracer() | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. (non-blocking comment) There was a mention in the past about allowing users to optionally specify their own |
||
|
||
span = tracer.create_span( | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We will be changing There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yes, PR #290 adapted the code, whoever is merged second will have to resolve merge conflicts. |
||
span_name, parent_span, kind=trace.SpanKind.SERVER | ||
) | ||
span.start(environ.get(_ENVIRON_STARTTIME_KEY)) | ||
activation = tracer.use_span(span, end_on_exit=True) | ||
activation.__enter__() | ||
environ[_ENVIRON_ACTIVATION_KEY] = activation | ||
environ[_ENVIRON_SPAN_KEY] = span | ||
otel_wsgi.add_request_attributes(span, environ) | ||
if flask_request.url_rule: | ||
# For 404 that result from no route found, etc, we don't have a url_rule. | ||
span.set_attribute("http.route", flask_request.url_rule.rule) | ||
|
||
|
||
def _teardown_flask_request(exc): | ||
activation = flask_request.environ.get(_ENVIRON_ACTIVATION_KEY) | ||
if not activation: | ||
logger.warning( | ||
"Flask environ's OpenTelemetry activation missing at _teardown_flask_request(%s)", | ||
exc, | ||
) | ||
return | ||
|
||
if exc is None: | ||
activation.__exit__(None, None, None) | ||
else: | ||
activation.__exit__( | ||
type(exc), exc, getattr(exc, "__traceback__", None) | ||
) |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,15 @@ | ||
# Copyright 2019, OpenTelemetry Authors | ||
# | ||
# Licensed under the Apache License, Version 2.0 (the "License"); | ||
# you may not use this file except in compliance with the License. | ||
# You may obtain a copy of the License at | ||
# | ||
# http://www.apache.org/licenses/LICENSE-2.0 | ||
# | ||
# Unless required by applicable law or agreed to in writing, software | ||
# distributed under the License is distributed on an "AS IS" BASIS, | ||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
# See the License for the specific language governing permissions and | ||
# limitations under the License. | ||
|
||
__version__ = "0.3dev0" |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,138 @@ | ||
# Copyright 2019, OpenTelemetry Authors | ||
# | ||
# Licensed under the Apache License, Version 2.0 (the "License"); | ||
# you may not use this file except in compliance with the License. | ||
# You may obtain a copy of the License at | ||
# | ||
# http://www.apache.org/licenses/LICENSE-2.0 | ||
# | ||
# Unless required by applicable law or agreed to in writing, software | ||
# distributed under the License is distributed on an "AS IS" BASIS, | ||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
# See the License for the specific language governing permissions and | ||
# limitations under the License. | ||
|
||
import unittest | ||
|
||
from flask import Flask | ||
from werkzeug.test import Client | ||
from werkzeug.wrappers import BaseResponse | ||
|
||
import opentelemetry.ext.flask as otel_flask | ||
from opentelemetry import trace as trace_api | ||
from opentelemetry.ext.testutil.wsgitestutil import WsgiTestBase | ||
|
||
|
||
class TestFlaskIntegration(WsgiTestBase): | ||
def setUp(self): | ||
super().setUp() | ||
|
||
self.span_attrs = {} | ||
|
||
def setspanattr(key, value): | ||
self.assertIsInstance(key, str) | ||
self.span_attrs[key] = value | ||
|
||
self.span.set_attribute = setspanattr | ||
|
||
self.app = Flask(__name__) | ||
|
||
def hello_endpoint(helloid): | ||
if helloid == 500: | ||
raise ValueError(":-(") | ||
return "Hello: " + str(helloid) | ||
|
||
self.app.route("/hello/<int:helloid>")(hello_endpoint) | ||
|
||
otel_flask.instrument_app(self.app) | ||
self.client = Client(self.app, BaseResponse) | ||
|
||
def test_simple(self): | ||
resp = self.client.get("/hello/123") | ||
self.assertEqual(200, resp.status_code) | ||
self.assertEqual([b"Hello: 123"], list(resp.response)) | ||
|
||
self.create_span.assert_called_with( | ||
"hello_endpoint", | ||
trace_api.INVALID_SPAN_CONTEXT, | ||
kind=trace_api.SpanKind.SERVER, | ||
) | ||
self.assertEqual(1, self.span.start.call_count) | ||
|
||
# Nope, this uses Tracer.use_span(end_on_exit) | ||
# self.assertEqual(1, self.span.end.call_count) | ||
Oberon00 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
# TODO: Change this test to use the SDK, as mocking becomes painful | ||
|
||
self.assertEqual( | ||
self.span_attrs, | ||
{ | ||
"component": "http", | ||
"http.method": "GET", | ||
"http.host": "localhost", | ||
"http.url": "http://localhost/hello/123", | ||
"http.route": "/hello/<int:helloid>", | ||
"http.status_code": 200, | ||
"http.status_text": "OK", | ||
}, | ||
) | ||
|
||
def test_404(self): | ||
resp = self.client.post("/bye") | ||
self.assertEqual(404, resp.status_code) | ||
resp.close() | ||
|
||
self.create_span.assert_called_with( | ||
"/bye", | ||
trace_api.INVALID_SPAN_CONTEXT, | ||
kind=trace_api.SpanKind.SERVER, | ||
) | ||
self.assertEqual(1, self.span.start.call_count) | ||
|
||
# Nope, this uses Tracer.use_span(end_on_exit) | ||
# self.assertEqual(1, self.span.end.call_count) | ||
# TODO: Change this test to use the SDK, as mocking becomes painful | ||
|
||
self.assertEqual( | ||
self.span_attrs, | ||
{ | ||
"component": "http", | ||
"http.method": "POST", | ||
"http.host": "localhost", | ||
"http.url": "http://localhost/bye", | ||
"http.status_code": 404, | ||
"http.status_text": "NOT FOUND", | ||
}, | ||
) | ||
|
||
def test_internal_error(self): | ||
resp = self.client.get("/hello/500") | ||
self.assertEqual(500, resp.status_code) | ||
resp.close() | ||
|
||
self.create_span.assert_called_with( | ||
"hello_endpoint", | ||
trace_api.INVALID_SPAN_CONTEXT, | ||
kind=trace_api.SpanKind.SERVER, | ||
) | ||
self.assertEqual(1, self.span.start.call_count) | ||
|
||
# Nope, this uses Tracer.use_span(end_on_exit) | ||
# self.assertEqual(1, self.span.end.call_count) | ||
# TODO: Change this test to use the SDK, as mocking becomes painful | ||
|
||
self.assertEqual( | ||
self.span_attrs, | ||
{ | ||
"component": "http", | ||
"http.method": "GET", | ||
"http.host": "localhost", | ||
"http.url": "http://localhost/hello/500", | ||
"http.route": "/hello/<int:helloid>", | ||
"http.status_code": 500, | ||
"http.status_text": "INTERNAL SERVER ERROR", | ||
}, | ||
) | ||
|
||
|
||
if __name__ == "__main__": | ||
unittest.main() |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🤬
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You think I should tone down the emojis? 😁