uw-it-flask-gunicorn-json-logger

This is a flask extension that configures logging for applications running behind gunicorn.

By default, when running behind gunicorn, all logging goes to the gunicorn.error log, which is misleading, and also can show up as errors in other contexts.

This extension logs to a special child of the default: gunicorn.error.app and comes with the logging configuration to emit the .app logstream as json so that it can be directly queried and examined without guesswork.

Each log statement handled by the JsonFormatter will have additional information appended based on the request/session status at the time the statement was issued.

The output json will filter out the gunicorn.error string from the logger name, so that any children of your app logger will also be free of the misleading "error" context, which allows you to scope and process logs easily.

Installation

pip install uw-it-flask-gunicorn-logger

Basic use

You can use this extension with minimal configuration for default use cases:

from flask import Flask
from uw_it.flask_util.logger import FlaskJSONLogger
app = Flask(__name__)
FlaskJSONLogger(app)
app.logger.info("Info statement!")

This would generate a log that looked like:

{
  "severity": "INFO",
  "message": "Info statement!",
  "line": "example.py#__main__:5",
  "logger": "app",
  "thread": 123,
}

Session & Request Info

If your log was created inside a request context, request information will be appended also:

from flask import Flask, request, session
from uw_it.flask_util.logger import FlaskJSONLogger

app = Flask(__name__)
app.config.setdefault('LOG_SESSION_USER_KEY', 'username')
FlaskJSONLogger(app)

@app.route('/')
def index():
    username = request.headers.get('X-Forwarded-User') or session.get('username')
    session['username'] = username
    if username:
        app.logger.info(f"User {username} is here!")
        return f'Hello, {username}', 200
    else:
        app.logger.warning("An unauthorized user attempted access!")
        return 'Unauthorized', 401

Now, the log output would look something like this for a signed in user:

{
  "severity": "INFO",
  "message": "User guest is here!",
  "line": "app.py#index:5",
  "logger": "app",
  "thread": 123,
  "request": {
    "method": "GET",
    "url": "http://localhost:5000",
    "remoteIp": "127.0.0.1",
    "id": 123456789,
    "username": "guest"
  }
}

Adding extra annotations

You can also add any extra annotations that you like, by using the extra argument on the log statement. This will allow you to access arbitrary contextual data from within your logs:

import flask
app = flask.Flask(__name__)


@app.route("/submit", methods=("POST",))
def submit_form():
    form_data = request.form
    app.logger.info("Processing form", extra={"formData": form_data})

The above snippet would append something like this to your output:

{
  "severity": "INFO",
  "message": "Processing form",
  "line": "app.py#index:5",
  "logger": "app",
  "thread": 123,
  "request": {
    "method": "POST",
    "url": "http://localhost:5000/submit",
    "remoteIp": "127.0.0.1",
    "id": 123456789,
  },
  "formData": {
      "name": "Lee Vit",
      "age": 42,
      "favoriteColor": "suprablue",
  }
}

Adding timers

You can add timers to your request methods to get the full snapshot of, for instance, request payload, user, and the time a certain operation took. This can be very helpful for finding bottlenecks!

In the example app you can use the /timeout/5 path to simulate a long query that breaches a timing threshold and generates the appropriate logs.

Let's extend the above example and add a timer:

from flask import Flask
from uw_it.flask_util.logger import FlaskJSONLogger, logged_timer

app = Flask(__name__)
FlaskJSONLogger(app)

@app.route('/submit', methods=('POST',))
@logged_timer(threshold=5, namespace='slow')
def submit_form():
    # ...

Now, whenever our users hit the /submit route, the request will be logged to the special timer log any time it takes longer than 5 seconds to process the request.

Here is an example of what this might look like:

{
  "severity": "WARNING",
  "message": "Timer result: 20.34",
  "line": "logger.py#logged_timer#47",
  "logger": "app.timer.slow",
  "thread": 123,
  "request": {
    "method": "POST",
    "url": "http://localhost:5000/submit",
    "remoteIp": "127.0.0.1",
    "id": 123456789,
  },
  "formData": {
      "name": "Lee Vit",
      "age": 42,
      "favoriteColor": "suprablue",
  },
  "timer": {
    "startTime": 1655225002.643874,
    "endTime": 1655225022.981268,
    "elapsedTime": 20.33739399909973,
    "timedFunc": "submit_form",
  }
}

App Config Settings

The following app config settings are available for you to use:

Setting Description Default
LOG_CONFIG_FILENAME The filename to use to load log configuration, if not the default uw_it/flask_util/flask_gunicorn_log_config.yml
LOG_CONFIG_FILE_TYPE Valid values are json and yaml yaml
LOG_CONFIG_APP_LEVEL The default level to set for the app logger INFO
LOG_CONFIG_TRACEBACK_LIMIT How many lines of traceback you want to include in error logs 20

Customizing Configuration

If you aren't happy with or want to extend the default configuration supplied by the extension, you copy the default configuration into your project and update it as desired.

python -m uw_it.flask_util.logger create_config out.yaml

This will output a file, out.yaml, with the log configuration copied from the default. You can override and amend this as desired. Then, set the LOG_CONFIG_FILENAME app config value to out.yaml.

Note: You can use either .json or .yaml files; if you choose to use json, you must also set LOG_CONFIG_FILE_TYPE to json. By default, all input and output is assumed to be yaml.

Querying GKE Logs

Because of the structured and predictable format of the JSON logs, they can be easily queried.

Using the complete example from above as a reference, here are some example queries you might try:

  • Show me all breaching timer results
jsonPayload.logger = "app.timer"
severity >= "WARNING"
  • Show me all messages for a single request
jsonPayload.request.id = 123456789
  • Show me all requests to /submit that took longer than 20 seconds to process:
jsonPayload.timer.timedFunc = "submit_form"
jsonPayload.timer.elapsedTime > 20

Development

Making pull requests

Pull requests are welcome. There is no automation for releasing and reviewing at this time.

Before making a pull request, please run: poetry run black uw_it to format your code according to the black specifications.

Testing

There are currently no tests. Feel free to add some, along with additional examples to examples/example_app.py.

Publishing a new release

This is currently not automated, and operates on best intentions.

  • Update pyproject.toml with the new release version number
  • Run poetry update
  • Run poetry build
  • Run poetry publish

The login and password for PyPI are in the UW-IT IAM Mosler Vault.

Running the example app

FLASK_ENV=development FLASK_DEBUG=1 poetry run python -m examples.example_app