Skip to content

Commit

Permalink
Add Flask integration based on WSGI ext (#206)
Browse files Browse the repository at this point in the history
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 #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).
  • Loading branch information
Oberon00 authored and c24t committed Nov 23, 2019
1 parent 077a08e commit da8b8d9
Show file tree
Hide file tree
Showing 18 changed files with 603 additions and 135 deletions.
2 changes: 1 addition & 1 deletion examples/opentelemetry-example-app/setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@
"opentelemetry-api",
"opentelemetry-sdk",
"opentelemetry-ext-http-requests",
"opentelemetry-ext-wsgi",
"opentelemetry-ext-flask",
"flask",
"requests",
],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@

import opentelemetry.ext.http_requests
from opentelemetry import propagators, trace
from opentelemetry.ext.wsgi import OpenTelemetryMiddleware
from opentelemetry.ext.flask import instrument_app
from opentelemetry.sdk.context.propagation.b3_format import B3Format
from opentelemetry.sdk.trace import Tracer

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


app = flask.Flask(__name__)
Expand Down
35 changes: 35 additions & 0 deletions ext/opentelemetry-ext-flask/README.rst
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>`_
50 changes: 50 additions & 0 deletions ext/opentelemetry-ext-flask/setup.cfg
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
26 changes: 26 additions & 0 deletions ext/opentelemetry-ext-flask/setup.py
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()

span = tracer.create_span(
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)
)
15 changes: 15 additions & 0 deletions ext/opentelemetry-ext-flask/src/opentelemetry/ext/flask/version.py
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"
Empty file.
136 changes: 136 additions & 0 deletions ext/opentelemetry-ext-flask/tests/test_flask_integration.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
# 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)

# 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()
Loading

0 comments on commit da8b8d9

Please sign in to comment.