Skip to content
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

Merged
merged 17 commits into from
Nov 23, 2019
Merged
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. ✨
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤬

Copy link
Member Author

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? 😁


@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 = cncf-opentelemetry-contributors@lists.cncf.io
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()
Copy link
Contributor

Choose a reason for hiding this comment

The 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 Tracer instead of relying on the global one. Probably something to consider adding in the future ;)


span = tracer.create_span(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We will be changing create_span() to not exist in the public API, IIRC?

Copy link
Member Author

Choose a reason for hiding this comment

The 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)
)
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