Prometheus metrics and API Star

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.