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 Instrumentation fixes #601

Merged
merged 25 commits into from
May 5, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -34,15 +34,15 @@
trace.set_tracer_provider(TracerProvider())

opentelemetry.ext.requests.RequestsInstrumentor().instrument()
FlaskInstrumentor().instrument()

trace.get_tracer_provider().add_span_processor(
SimpleExportSpanProcessor(ConsoleSpanExporter())
)


app = flask.Flask(__name__)

Choose a reason for hiding this comment

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

Not related to this PR, but is this example missing app.run(), how am I supposed to run it?

FlaskInstrumentor().instrument_app(app)


@app.route("/")
def hello():
Expand Down
7 changes: 3 additions & 4 deletions docs/getting-started.rst
Original file line number Diff line number Diff line change
Expand Up @@ -184,9 +184,6 @@ And let's write a small Flask application that sends an HTTP request, activating
.. code-block:: python

# flask_example.py
from opentelemetry.ext.flask import FlaskInstrumentor
FlaskInstrumentor().instrument() # This needs to be executed before importing Flask

import flask
import requests

Expand All @@ -195,14 +192,16 @@ And let's write a small Flask application that sends an HTTP request, activating
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import ConsoleSpanExporter
from opentelemetry.sdk.trace.export import SimpleExportSpanProcessor
from opentelemetry.ext.flask import FlaskInstrumentor

trace.set_tracer_provider(TracerProvider())
trace.get_tracer_provider().add_span_processor(
SimpleExportSpanProcessor(ConsoleSpanExporter())
)

app = flask.Flask(__name__)
opentelemetry.ext.requests.RequestsInstrumentor().instrument()
FlaskInstrumentor().instrument_app(app)
opentelemetry.ext.http_requests.RequestsInstrumentor().instrument()

@app.route("/")
def hello():
Expand Down
225 changes: 130 additions & 95 deletions ext/opentelemetry-ext-flask/src/opentelemetry/ext/flask/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,12 +29,13 @@

.. code-block:: python

from opentelemetry.ext.flask import FlaskInstrumentor
FlaskInstrumentor().instrument() # This needs to be executed before importing Flask
from flask import Flask
from opentelemetry.ext.flask import FlaskInstrumentor

app = Flask(__name__)

FlaskInstrumentor().instrument_app(app)

@app.route("/")
def hello():
return "Hello!"
Expand All @@ -46,7 +47,7 @@ def hello():
---
"""

import logging
from logging import getLogger

import flask

Expand All @@ -60,110 +61,112 @@ def hello():
time_ns,
)

logger = logging.getLogger(__name__)
_logger = getLogger(__name__)

_ENVIRON_STARTTIME_KEY = "opentelemetry-flask.starttime_key"
_ENVIRON_SPAN_KEY = "opentelemetry-flask.span_key"
_ENVIRON_ACTIVATION_KEY = "opentelemetry-flask.activation_key"
_ENVIRON_TOKEN = "opentelemetry-flask.token"


def _rewrapped_app(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):

if not _disable_trace(flask.request.url):

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_app(environ, _start_response)

return _wrapped_app


def _before_request():
if _disable_trace(flask.request.url):
return

environ = flask.request.environ
span_name = flask.request.endpoint or otel_wsgi.get_default_span_name(
environ
)
token = context.attach(
propagators.extract(otel_wsgi.get_header_from_environ, environ)
)

tracer = trace.get_tracer(__name__, __version__)

attributes = otel_wsgi.collect_request_attributes(environ)
if flask.request.url_rule:
# For 404 that result from no route found, etc, we
# don't have a url_rule.
attributes["http.route"] = flask.request.url_rule.rule
span = tracer.start_span(
span_name,
kind=trace.SpanKind.SERVER,
attributes=attributes,
start_time=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
environ[_ENVIRON_TOKEN] = token


def _teardown_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)
)
context.detach(flask.request.environ.get(_ENVIRON_TOKEN))


class _InstrumentedFlask(flask.Flask):
def __init__(self, *args, **kwargs):

super().__init__(*args, **kwargs)

# Single use variable here to avoid recursion issues.
wsgi = self.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):
if not _disable_trace(flask.request.url):
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)

self.wsgi_app = wrapped_app

@self.before_request
def _before_flask_request():
# Do not trace if the url is excluded
if _disable_trace(flask.request.url):
return
environ = flask.request.environ
span_name = (
flask.request.endpoint
or otel_wsgi.get_default_span_name(environ)
)
token = context.attach(
propagators.extract(otel_wsgi.get_header_from_environ, environ)
)
self._original_wsgi_ = self.wsgi_app
self.wsgi_app = _rewrapped_app(self.wsgi_app)

tracer = trace.get_tracer(__name__, __version__)

attributes = otel_wsgi.collect_request_attributes(environ)
if flask.request.url_rule:
# For 404 that result from no route found, etc, we
# don't have a url_rule.
attributes["http.route"] = flask.request.url_rule.rule
span = tracer.start_span(
span_name,
kind=trace.SpanKind.SERVER,
attributes=attributes,
start_time=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
environ[_ENVIRON_TOKEN] = token

@self.teardown_request
def _teardown_flask_request(exc):
# Not traced if the url is excluded
if _disable_trace(flask.request.url):
return
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)
)
context.detach(flask.request.environ.get(_ENVIRON_TOKEN))
self.before_request(_before_request)
self.teardown_request(_teardown_request)


def _disable_trace(url):
excluded_hosts = configuration.Configuration().FLASK_EXCLUDED_HOSTS
excluded_paths = configuration.Configuration().FLASK_EXCLUDED_PATHS

if excluded_hosts:
excluded_hosts = str.split(excluded_hosts, ",")
if disable_tracing_hostname(url, excluded_hosts):
Expand All @@ -176,18 +179,50 @@ def _disable_trace(url):


class FlaskInstrumentor(BaseInstrumentor):
"""A instrumentor for flask.Flask
# pylint: disable=protected-access,attribute-defined-outside-init
"""An instrumentor for flask.Flask

See `BaseInstrumentor`
"""

def __init__(self):
super().__init__()
self._original_flask = None

def _instrument(self, **kwargs):
self._original_flask = flask.Flask
flask.Flask = _InstrumentedFlask

def instrument_app(self, app): # pylint: disable=no-self-use
if not hasattr(app, "_is_instrumented"):
app._is_instrumented = False

if not app._is_instrumented:
app._original_wsgi_app = app.wsgi_app
app.wsgi_app = _rewrapped_app(app.wsgi_app)

app.before_request(_before_request)
app.teardown_request(_teardown_request)
app._is_instrumented = True
else:
_logger.warning(
"Attempting to instrument Flask app while already instrumented"
)

def _uninstrument(self, **kwargs):
flask.Flask = self._original_flask

def uninstrument_app(self, app): # pylint: disable=no-self-use
if not hasattr(app, "_is_instrumented"):
app._is_instrumented = False

if app._is_instrumented:
app.wsgi_app = app._original_wsgi_app

# FIXME add support for other Flask blueprints that are not None
app.before_request_funcs[None].remove(_before_request)
Copy link
Member

Choose a reason for hiding this comment

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

have you considered using the instrumented flag to turn off and on behavior, like the pymongo instrumentation?

Or you could remove by value by comparing the id of the function:

for k, v in before_request_funcs.items():
    if v is _before_request:
        del before_request_funcs[k]

Although the latter feels more error prone as you are relying on the data structure remaining the same across different versions of flask. The checking of the _is_instrumented value feels more robust here.

app.teardown_request_funcs[None].remove(_teardown_request)
del app._original_wsgi_app

app._is_instrumented = False
else:
_logger.warning(
"Attempting to uninstrument Flask "
"app while already uninstrumented"
)
Loading