Skip to content

Commit c074853

Browse files
flask: Add Flask Instrumentation fixes (#601)
Reffactoring the Flask instrumentation to better enable auto-instrumentation. Co-authored-by: Mauricio Vásquez <[email protected]>
1 parent e119285 commit c074853

File tree

8 files changed

+279
-160
lines changed

8 files changed

+279
-160
lines changed

Diff for: docs/examples/opentelemetry-example-app/src/opentelemetry_example_app/flask_example.py

+2-2
Original file line numberDiff line numberDiff line change
@@ -34,15 +34,15 @@
3434
trace.set_tracer_provider(TracerProvider())
3535

3636
opentelemetry.ext.requests.RequestsInstrumentor().instrument()
37-
FlaskInstrumentor().instrument()
3837

3938
trace.get_tracer_provider().add_span_processor(
4039
SimpleExportSpanProcessor(ConsoleSpanExporter())
4140
)
4241

43-
4442
app = flask.Flask(__name__)
4543

44+
FlaskInstrumentor().instrument_app(app)
45+
4646

4747
@app.route("/")
4848
def hello():

Diff for: docs/getting-started.rst

+3-4
Original file line numberDiff line numberDiff line change
@@ -184,9 +184,6 @@ And let's write a small Flask application that sends an HTTP request, activating
184184
.. code-block:: python
185185
186186
# flask_example.py
187-
from opentelemetry.ext.flask import FlaskInstrumentor
188-
FlaskInstrumentor().instrument() # This needs to be executed before importing Flask
189-
190187
import flask
191188
import requests
192189
@@ -195,14 +192,16 @@ And let's write a small Flask application that sends an HTTP request, activating
195192
from opentelemetry.sdk.trace import TracerProvider
196193
from opentelemetry.sdk.trace.export import ConsoleSpanExporter
197194
from opentelemetry.sdk.trace.export import SimpleExportSpanProcessor
195+
from opentelemetry.ext.flask import FlaskInstrumentor
198196
199197
trace.set_tracer_provider(TracerProvider())
200198
trace.get_tracer_provider().add_span_processor(
201199
SimpleExportSpanProcessor(ConsoleSpanExporter())
202200
)
203201
204202
app = flask.Flask(__name__)
205-
opentelemetry.ext.requests.RequestsInstrumentor().instrument()
203+
FlaskInstrumentor().instrument_app(app)
204+
opentelemetry.ext.http_requests.RequestsInstrumentor().instrument()
206205
207206
@app.route("/")
208207
def hello():

Diff for: ext/opentelemetry-ext-flask/src/opentelemetry/ext/flask/__init__.py

+130-95
Original file line numberDiff line numberDiff line change
@@ -29,12 +29,13 @@
2929
3030
.. code-block:: python
3131
32-
from opentelemetry.ext.flask import FlaskInstrumentor
33-
FlaskInstrumentor().instrument() # This needs to be executed before importing Flask
3432
from flask import Flask
33+
from opentelemetry.ext.flask import FlaskInstrumentor
3534
3635
app = Flask(__name__)
3736
37+
FlaskInstrumentor().instrument_app(app)
38+
3839
@app.route("/")
3940
def hello():
4041
return "Hello!"
@@ -46,7 +47,7 @@ def hello():
4647
---
4748
"""
4849

49-
import logging
50+
from logging import getLogger
5051

5152
import flask
5253

@@ -60,110 +61,112 @@ def hello():
6061
time_ns,
6162
)
6263

63-
logger = logging.getLogger(__name__)
64+
_logger = getLogger(__name__)
6465

6566
_ENVIRON_STARTTIME_KEY = "opentelemetry-flask.starttime_key"
6667
_ENVIRON_SPAN_KEY = "opentelemetry-flask.span_key"
6768
_ENVIRON_ACTIVATION_KEY = "opentelemetry-flask.activation_key"
6869
_ENVIRON_TOKEN = "opentelemetry-flask.token"
6970

7071

72+
def _rewrapped_app(wsgi_app):
73+
def _wrapped_app(environ, start_response):
74+
# We want to measure the time for route matching, etc.
75+
# In theory, we could start the span here and use
76+
# update_name later but that API is "highly discouraged" so
77+
# we better avoid it.
78+
environ[_ENVIRON_STARTTIME_KEY] = time_ns()
79+
80+
def _start_response(status, response_headers, *args, **kwargs):
81+
82+
if not _disable_trace(flask.request.url):
83+
84+
span = flask.request.environ.get(_ENVIRON_SPAN_KEY)
85+
86+
if span:
87+
otel_wsgi.add_response_attributes(
88+
span, status, response_headers
89+
)
90+
else:
91+
_logger.warning(
92+
"Flask environ's OpenTelemetry span "
93+
"missing at _start_response(%s)",
94+
status,
95+
)
96+
97+
return start_response(status, response_headers, *args, **kwargs)
98+
99+
return wsgi_app(environ, _start_response)
100+
101+
return _wrapped_app
102+
103+
104+
def _before_request():
105+
if _disable_trace(flask.request.url):
106+
return
107+
108+
environ = flask.request.environ
109+
span_name = flask.request.endpoint or otel_wsgi.get_default_span_name(
110+
environ
111+
)
112+
token = context.attach(
113+
propagators.extract(otel_wsgi.get_header_from_environ, environ)
114+
)
115+
116+
tracer = trace.get_tracer(__name__, __version__)
117+
118+
attributes = otel_wsgi.collect_request_attributes(environ)
119+
if flask.request.url_rule:
120+
# For 404 that result from no route found, etc, we
121+
# don't have a url_rule.
122+
attributes["http.route"] = flask.request.url_rule.rule
123+
span = tracer.start_span(
124+
span_name,
125+
kind=trace.SpanKind.SERVER,
126+
attributes=attributes,
127+
start_time=environ.get(_ENVIRON_STARTTIME_KEY),
128+
)
129+
activation = tracer.use_span(span, end_on_exit=True)
130+
activation.__enter__()
131+
environ[_ENVIRON_ACTIVATION_KEY] = activation
132+
environ[_ENVIRON_SPAN_KEY] = span
133+
environ[_ENVIRON_TOKEN] = token
134+
135+
136+
def _teardown_request(exc):
137+
activation = flask.request.environ.get(_ENVIRON_ACTIVATION_KEY)
138+
if not activation:
139+
_logger.warning(
140+
"Flask environ's OpenTelemetry activation missing"
141+
"at _teardown_flask_request(%s)",
142+
exc,
143+
)
144+
return
145+
146+
if exc is None:
147+
activation.__exit__(None, None, None)
148+
else:
149+
activation.__exit__(
150+
type(exc), exc, getattr(exc, "__traceback__", None)
151+
)
152+
context.detach(flask.request.environ.get(_ENVIRON_TOKEN))
153+
154+
71155
class _InstrumentedFlask(flask.Flask):
72156
def __init__(self, *args, **kwargs):
73-
74157
super().__init__(*args, **kwargs)
75158

76-
# Single use variable here to avoid recursion issues.
77-
wsgi = self.wsgi_app
78-
79-
def wrapped_app(environ, start_response):
80-
# We want to measure the time for route matching, etc.
81-
# In theory, we could start the span here and use
82-
# update_name later but that API is "highly discouraged" so
83-
# we better avoid it.
84-
environ[_ENVIRON_STARTTIME_KEY] = time_ns()
85-
86-
def _start_response(status, response_headers, *args, **kwargs):
87-
if not _disable_trace(flask.request.url):
88-
span = flask.request.environ.get(_ENVIRON_SPAN_KEY)
89-
if span:
90-
otel_wsgi.add_response_attributes(
91-
span, status, response_headers
92-
)
93-
else:
94-
logger.warning(
95-
"Flask environ's OpenTelemetry span "
96-
"missing at _start_response(%s)",
97-
status,
98-
)
99-
100-
return start_response(
101-
status, response_headers, *args, **kwargs
102-
)
103-
104-
return wsgi(environ, _start_response)
105-
106-
self.wsgi_app = wrapped_app
107-
108-
@self.before_request
109-
def _before_flask_request():
110-
# Do not trace if the url is excluded
111-
if _disable_trace(flask.request.url):
112-
return
113-
environ = flask.request.environ
114-
span_name = (
115-
flask.request.endpoint
116-
or otel_wsgi.get_default_span_name(environ)
117-
)
118-
token = context.attach(
119-
propagators.extract(otel_wsgi.get_header_from_environ, environ)
120-
)
159+
self._original_wsgi_ = self.wsgi_app
160+
self.wsgi_app = _rewrapped_app(self.wsgi_app)
121161

122-
tracer = trace.get_tracer(__name__, __version__)
123-
124-
attributes = otel_wsgi.collect_request_attributes(environ)
125-
if flask.request.url_rule:
126-
# For 404 that result from no route found, etc, we
127-
# don't have a url_rule.
128-
attributes["http.route"] = flask.request.url_rule.rule
129-
span = tracer.start_span(
130-
span_name,
131-
kind=trace.SpanKind.SERVER,
132-
attributes=attributes,
133-
start_time=environ.get(_ENVIRON_STARTTIME_KEY),
134-
)
135-
activation = tracer.use_span(span, end_on_exit=True)
136-
activation.__enter__()
137-
environ[_ENVIRON_ACTIVATION_KEY] = activation
138-
environ[_ENVIRON_SPAN_KEY] = span
139-
environ[_ENVIRON_TOKEN] = token
140-
141-
@self.teardown_request
142-
def _teardown_flask_request(exc):
143-
# Not traced if the url is excluded
144-
if _disable_trace(flask.request.url):
145-
return
146-
activation = flask.request.environ.get(_ENVIRON_ACTIVATION_KEY)
147-
if not activation:
148-
logger.warning(
149-
"Flask environ's OpenTelemetry activation missing"
150-
"at _teardown_flask_request(%s)",
151-
exc,
152-
)
153-
return
154-
155-
if exc is None:
156-
activation.__exit__(None, None, None)
157-
else:
158-
activation.__exit__(
159-
type(exc), exc, getattr(exc, "__traceback__", None)
160-
)
161-
context.detach(flask.request.environ.get(_ENVIRON_TOKEN))
162+
self.before_request(_before_request)
163+
self.teardown_request(_teardown_request)
162164

163165

164166
def _disable_trace(url):
165167
excluded_hosts = configuration.Configuration().FLASK_EXCLUDED_HOSTS
166168
excluded_paths = configuration.Configuration().FLASK_EXCLUDED_PATHS
169+
167170
if excluded_hosts:
168171
excluded_hosts = str.split(excluded_hosts, ",")
169172
if disable_tracing_hostname(url, excluded_hosts):
@@ -176,18 +179,50 @@ def _disable_trace(url):
176179

177180

178181
class FlaskInstrumentor(BaseInstrumentor):
179-
"""A instrumentor for flask.Flask
182+
# pylint: disable=protected-access,attribute-defined-outside-init
183+
"""An instrumentor for flask.Flask
180184
181185
See `BaseInstrumentor`
182186
"""
183187

184-
def __init__(self):
185-
super().__init__()
186-
self._original_flask = None
187-
188188
def _instrument(self, **kwargs):
189189
self._original_flask = flask.Flask
190190
flask.Flask = _InstrumentedFlask
191191

192+
def instrument_app(self, app): # pylint: disable=no-self-use
193+
if not hasattr(app, "_is_instrumented"):
194+
app._is_instrumented = False
195+
196+
if not app._is_instrumented:
197+
app._original_wsgi_app = app.wsgi_app
198+
app.wsgi_app = _rewrapped_app(app.wsgi_app)
199+
200+
app.before_request(_before_request)
201+
app.teardown_request(_teardown_request)
202+
app._is_instrumented = True
203+
else:
204+
_logger.warning(
205+
"Attempting to instrument Flask app while already instrumented"
206+
)
207+
192208
def _uninstrument(self, **kwargs):
193209
flask.Flask = self._original_flask
210+
211+
def uninstrument_app(self, app): # pylint: disable=no-self-use
212+
if not hasattr(app, "_is_instrumented"):
213+
app._is_instrumented = False
214+
215+
if app._is_instrumented:
216+
app.wsgi_app = app._original_wsgi_app
217+
218+
# FIXME add support for other Flask blueprints that are not None
219+
app.before_request_funcs[None].remove(_before_request)
220+
app.teardown_request_funcs[None].remove(_teardown_request)
221+
del app._original_wsgi_app
222+
223+
app._is_instrumented = False
224+
else:
225+
_logger.warning(
226+
"Attempting to uninstrument Flask "
227+
"app while already uninstrumented"
228+
)

0 commit comments

Comments
 (0)