-
Notifications
You must be signed in to change notification settings - Fork 624
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
Changes from all commits
e5260e0
d907144
1c29d9d
07d5882
6dd12a4
cf71555
a19849c
d45c4f0
139f1aa
820f519
91c1b47
6e61557
108287d
636edc2
d1c7127
baa142d
83c7a97
cda10f4
7316bcf
60f9693
9e8a5b4
2e56c53
7d285c9
d428dac
aa9c923
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 |
---|---|---|
|
@@ -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!" | ||
|
@@ -46,7 +47,7 @@ def hello(): | |
--- | ||
""" | ||
|
||
import logging | ||
from logging import getLogger | ||
|
||
import flask | ||
|
||
|
@@ -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): | ||
|
@@ -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) | ||
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. 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:
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" | ||
) |
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.
Not related to this PR, but is this example missing
app.run()
, how am I supposed to run it?