blob: 01a2f65ea749a57b50ac6785ffea994209edd8ca [file] [log] [blame]
# Copyright 2019 The Chromium Authors. All rights reserved.
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
import flask
import logging
import time
from infra_libs.ts_mon import exporter
from infra_libs.ts_mon import handlers
from infra_libs.ts_mon import shared
from infra_libs.ts_mon.common import http_metrics
from infra_libs.ts_mon.common import interface
def _is_instrumented(app):
"""Checks if FlaskInstrumentor() has been installed into the middleware chain.
"""
max_depth = 32
cur = app
while cur is not None and max_depth:
if isinstance(cur, FlaskInstrumentor):
return True
cur = getattr(cur, 'wsgi_app', None)
max_depth -= 1
if cur is not None and max_depth == 0:
raise Exception('Failed to check if the Flask is instrumented by tsmon; '
'max-depth for the middleware chain has been reached')
return False
def instrument(app, time_fn=time.time):
if not _is_instrumented(app):
app.wsgi_app = FlaskInstrumentor(app.wsgi_app, time_fn)
app.add_url_rule(
shared.CRON_REQUEST_PATH_TASKNUM_ASSIGNER, view_func=assign_task_num)
return app
class FlaskHTTPStat(object):
def __init__(self):
self.name = ''
self.request_content_length = 0
self.request_user_agent = ''
self.response_status = 0
self.response_content_length = 0
def _get_status_code(self, status):
"""Returns the status code from a given status string.
If the status contains an invalid value, 500 is returned instead.
"""
try:
s = status.split(None, 1)
return int(s[0])
# This would never happen. flask/werkzeug adds status-code 0 into the
# status line, if a page handler returns a status line with an invalid
# status code. Or, if a handler returns an empty status line, an exception
# is raised before this point reached.
except (IndexError, ValueError): # pragma: no cover
logging.exception('Invalid status code')
return 0
def _get_response_content_length(self, headers):
"""Returns the content length of the response.
If 'Content-Length' is missing from the response headers or the value is
not a valid number, then 0 is returned instead.
"""
try:
for h in headers:
if h[0] == 'Content-Length':
return int(h[1])
# werkzeug always generates Content-Length header, even if
# automatically_set_content_length is set with False, if a generated
# Response() doesn't have one. Therefore, this won't be reached.
return 0 # pragma: no cover
except ValueError:
logging.exception('Invalid Content-Length')
return 0
def parse(self, status, request, response_headers):
"""Parses the request context and response headers to get the stat info."""
try:
# If the requested path doesn't match with any of the existing rules,
# then url_rule is None. e.g., 404
if flask.request.url_rule is not None:
# Use the url rule, such as '/user/<int:id>', to prevent an explosion
# in possible field values.
self.name = request.url_rule.rule
self.request_user_agent = flask.request.user_agent.string
self.response_status = self._get_status_code(status)
self.response_content_length = self._get_response_content_length(
response_headers)
except Exception:
# tsmon bug? log the exception and report it with status-code 0.
# The actual HTTP response will be sent to the client, as original.
logging.exception("Failed to parse HTTP response")
self.response_status = 0
self.response_content_length = 0
class FlaskInstrumentor(object):
def __init__(self, app, time_fn):
self.app = app
self.time_fn = time_fn
def __call__(self, environ, start_response):
start_time = self.time_fn()
stat = FlaskHTTPStat()
def _start_response(status, headers, exc_info=None):
# This wraps the start_response to catch the request context and response.
stat.parse(status, flask.request, headers)
return start_response(status, headers, exc_info)
try:
# start a flush thread, if necessary.
with exporter.parallel_flush(start_time):
return self.app(environ, _start_response)
except Exception:
# If a uncaught exception occurs, then the default or custom
# handle_exception() is invoked to handle response, and _start_response()
# is not invoked. At this moment, the request context has been popped out,
# and stat contains empty string or 0 values.
stat.name = environ.get('PATH_INFO', '')
stat.response_status = 500
raise
finally:
elapsed_ms = int((self.time_fn() - start_time) * 1000)
http_metrics.update_http_server_metrics(
stat.name,
stat.response_status,
elapsed_ms,
request_size=stat.request_content_length,
response_size=stat.response_content_length,
user_agent=stat.request_user_agent)
@handlers.report_memory
def assign_task_num():
"""Assigns a unique task num into each of the active Appengine instances."""
if flask.request.headers.get('X-Appengine-Cron') != 'true':
flask.abort(403)
with shared.instance_namespace_context():
handlers._assign_task_num()
interface.invoke_global_callbacks()
return "OK"