This past week I started playing with API Star and I'm kind of in love with it right now. Being a new project, its docs are a bit lacking -- its source code, however, is high quality and easy to understand -- so it took me a little time to figure out a way to automatically track request and response metrics using Prometheus.
The Goal
My goal was to globally track request durations, request counts and the
number of requests in progress at any given time. AFAICT, there are two
major ways to do this with API Star: either write a WSGI middleware and
wrap the API star App
object or leverage a component along with
BEFORE_REQUEST
and AFTER_REQUEST
hooks. I ended up going with the
latter.
The Component
First I defined a Prometheus
component. In the app's lifecycle this is
a singleton (preload=True
) object that can be injected in the before
and after response hooks. It is used to keep track of some thread-local
state (like the start time of each request) and to update the prom
metrics.
import time
from apistar import http
from http import HTTPStatus
from prometheus_client import Counter, Gauge, Histogram
from threading import local
REQUEST_DURATION = Histogram(
"http_request_duration_seconds",
"Time spent processing a request.",
["method", "handler"],
)
REQUEST_COUNT = Counter(
"http_requests_total",
"Request count by method, handler and response code.",
["method", "handler", "code"],
)
REQUESTS_INPROGRESS = Gauge(
"http_requests_inprogress",
"Requests in progress by method and handler",
["method", "handler"],
)
class Prometheus:
def __init__(self):
self.data = local()
def track_request_start(self, method, handler):
self.data.start_time = time.monotonic()
handler_name = f"{handler.__module__}.{handler.__name__}"
REQUESTS_INPROGRESS.labels(method, handler_name).inc()
def track_request_end(self, method, handler, ret):
status = 200
if isinstance(ret, http.Response):
status = HTTPStatus(ret.status).value
handler_name = "<builtin>"
if handler is not None:
handler_name = f"{handler.__module__}.{handler.__name__}"
duration = time.monotonic() - self.data.start_time
del self.data.start_time
REQUEST_DURATION.labels(method, handler_name).observe(duration)
REQUEST_COUNT.labels(method, handler_name, status).inc()
REQUESTS_INPROGRESS.labels(method, handler_name).dec()
Thread-local data is stored in the data
property of the singleton and
the track_request_start
and track_request_end
methods are meant to
be called at the beginning and end of each request, respectively.
The Hooks
The hooks request the Prometheus
component along with information
about the current request method and handler. All they do is pass that
information along to track_request_start
and track_request_end
.
def before_request(prometheus: Prometheus,
method: http.Method,
handler: Handler):
prometheus.track_request_start(method, handler)
def after_request(prometheus: Prometheus,
method: http.Method,
handler: Handler,
ret: ReturnValue):
prometheus.track_request_end(method, handler, ret)
return ret
One odd thing I ran into was the fact that for builtin request handlers,
such as the 404 handler, the before_request
hook doesn't get called.
This is why track_request_end
checks whether or not the handler
is
None
before trying to compute the time spent running it. In those
cases, self.data.start_time
is never set because track_request_start
was never called.
The Exposition Handler
One problem I ran into while trying to expose the metrics was the fact that API Star doesn't seem to provide a plaintext renderer. Defining my own was straightforward enough:
from apistar import http
from apistar.renderers import Renderer
class PlaintextRenderer(Renderer):
def render(self, data: http.ResponseData) -> bytes:
return data
Finally, the exposition handler just calls the prom client's
generate_latest
function and renders it using the PlaintextRenderer
.
from apistar import Response, annotate
from prometheus_client import CONTENT_TYPE_LATEST
@annotate(renderers=[PlaintextRenderer()])
def expose_metrics():
return Response(generate_latest(), headers={
"content-type": CONTENT_TYPE_LATEST,
})
The App
To hook it all up, I added all the bits I defined previously to
the appropriate spots in the WSGIApp
's config, making sure the
before_request
and after_request
hooks were the first and last ones
to be added to the configuration, respectively.
import prometheus_component
from apistar import Component, Route, hooks
from apistar.frameworks.wsgi import WSGIApp as App
components = [
Component(prometheus_component.Prometheus, preload=True),
]
routes = [
Route("/metrics", "GET", prometheus_component.expose_metrics),
]
settings = {
"BEFORE_REQUEST": [
prometheus_component.before_request,
hooks.check_permissions,
],
"AFTER_REQUEST": [
hooks.render_response,
prometheus_component.after_request,
],
}
app = App(
components=components,
routes=routes,
settings=settings,
)
Epilogue
In the end I packed all of this up in a library so I could reuse the functionality across apps. You can find it here.
Like I said at the beginning, API Star has been a joy to use so far and I can't wait to play around with it some more. I'm likely going to put it into production soon so expect more content to come about it.