When to use this access log runbook

A small Flask blog can run for months with healthy pages and still have weak operational visibility. Nginx access logs show search crawler traffic, broken links, slow article pages, repeated admin probes, upload attempts, and unexpected 5xx responses. If the only habit is to run tail -f during an outage, the useful signals are usually gone by the time anyone investigates. For a site such as Stepnex, especially while preparing for ad network review, the goal is not to build a large observability platform. The goal is to turn yesterday's traffic into a short, repeatable quality review.

This runbook is designed for a single-server Flask + Gunicorn + Nginx blog. It keeps the original log files on the server, uses a structured Nginx log format, rotates the files with logrotate, and runs a small Python script every morning. The daily report should answer five questions: did real pages return 200, which URLs produced the most 404s, did any request return 5xx, which article pages were slow, and did admin or publishing endpoints receive suspicious traffic? It complements the existing Flask production health check runbook: health checks say whether the site works now; access log review explains what happened over the last day.

Start with a readable configuration

Do not begin with a heavyweight pipeline unless the team already operates one. The first version should be boring: native Nginx logs, logrotate, a Python parser, and a Markdown report. This keeps the failure mode simple. If the parser breaks, the raw log is still available. If the report looks noisy, the fields can be adjusted without changing the application. If the server is migrated, the same files and commands can move with it.

Use a JSON-like log line instead of the default combined format. Nginx documents log_format and the escape=json option, and access_log can attach the format to a server or location. For a Flask blog, keep only fields that help operations: timestamp, remote address, forwarded address, request line, method, URI, status, response size, request time, upstream response time, referrer, and user agent. Avoid sending full logs to third-party systems before deciding the retention and privacy boundary.

Step 1: configure JSON access logs in Nginx

The steps below assume a conventional Linux deployment where Nginx terminates HTTPS and proxies requests to Gunicorn on 127.0.0.1. If the site uses a Unix socket, a CDN, or a cloud load balancer, keep the same review model but adjust the real IP headers and log path. Write down the active topology before changing configuration: public domain, Nginx server block, upstream target, application user, log directory, and restart command. That small inventory prevents a common mistake where the operator edits a file under sites-available but the live virtual host is loaded from another include.

Place the format in the http block and the file path in the site server block. Keep article pages, static files, admin routes, uploads, sitemap, and robots traffic in the same site log so the daily report can group by URI.

http {
    log_format stepnex_json escape=json
      '{'
      '"time":"$time_iso8601",'
      '"remote_addr":"$remote_addr",'
      '"forwarded_for":"$http_x_forwarded_for",'
      '"request":"$request",'
      '"method":"$request_method",'
      '"uri":"$request_uri",'
      '"status":$status,'
      '"bytes":$body_bytes_sent,'
      '"request_time":$request_time,'
      '"upstream_time":"$upstream_response_time",'
      '"referer":"$http_referer",'
      '"user_agent":"$http_user_agent"'
      '}';

    server {
        server_name stepnex.cn www.stepnex.cn;
        access_log /var/log/nginx/stepnex.access.json.log stepnex_json buffer=64k flush=5s;
        error_log  /var/log/nginx/stepnex.error.log warn;

        location / {
            proxy_pass http://127.0.0.1:8000;
            proxy_set_header Host $host;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header X-Forwarded-Proto $scheme;
        }
    }
}

Validate before reloading:

sudo nginx -t
sudo systemctl reload nginx
sudo tail -n 5 /var/log/nginx/stepnex.access.json.log

Then request the home page, one Chinese article, one English article, /sitemap.xml, /robots.txt, an intentionally missing URL, and the admin login page. The log should show the expected URI, status, and request_time for each request. If the file remains empty, inspect the loaded configuration with sudo nginx -T | grep stepnex_json -n and confirm that the server block is the one receiving traffic.

Step 2: rotate logs before they become a problem

Access logs grow quietly. A small blog can keep detailed logs for 14 to 30 days and keep summarized reports longer. Create a dedicated logrotate rule:

/var/log/nginx/stepnex.access.json.log {
    daily
    rotate 30
    missingok
    notifempty
    compress
    delaycompress
    create 0640 www-data adm
    sharedscripts
    postrotate
        [ -s /run/nginx.pid ] && kill -USR1 `cat /run/nginx.pid`
    endscript
}

Run a dry check first:

sudo logrotate -d /etc/logrotate.d/stepnex-nginx

Validate the next morning that a rotated file exists and Nginx is still writing to the active file. A common pitfall is to use copytruncate without thinking. Nginx can reopen log files after receiving USR1, so prefer that normal path unless the deployment has a specific reason. Another pitfall is file ownership: the worker needs write permission and the operator needs read permission. Keep that boundary explicit.

Step 3: produce a daily Markdown report

The parser can stay small. It reads recent log lines, tolerates malformed rows, and prints counters that are useful for maintenance. The report should include status distribution, top 404 URLs, 5xx requests, slow requests, crawler counts, admin endpoint hits, upload endpoint hits, and unusual referrers.

import json
from collections import Counter
from pathlib import Path

log_path = Path("/var/log/nginx/stepnex.access.json.log")
status = Counter()
not_found = Counter()
server_errors = []
slow = []
bots = Counter()
admin_hits = Counter()

for line in log_path.read_text(encoding="utf-8", errors="ignore").splitlines():
    try:
        row = json.loads(line)
    except json.JSONDecodeError:
        continue

    code = int(row.get("status") or 0)
    uri = row.get("uri") or "-"
    agent = (row.get("user_agent") or "").lower()
    request_time = float(row.get("request_time") or 0)

    status[code] += 1
    if code == 404:
        not_found[uri] += 1
    if code >= 500:
        server_errors.append((code, request_time, uri))
    if request_time >= 1.0:
        slow.append((request_time, code, uri))
    if "baiduspider" in agent:
        bots["baidu"] += 1
    if "googlebot" in agent:
        bots["google"] += 1
    if uri.startswith("/admin") or uri.startswith("/api/articles/publish"):
        admin_hits[uri] += 1

print("# Nginx daily access log review")
print("status:", status.most_common())
print("bots:", bots.most_common())
print("top_404:", not_found.most_common(20))
print("server_errors:", server_errors[:30])
print("admin_hits:", admin_hits.most_common(20))
print("slow:", sorted(slow, reverse=True)[:30])

The script is intentionally plain. After the report is stable for a week, it can be wrapped in a proper command, written to /var/www/blog/ops-reports/, or sent by email. Do not write the full raw log into the public web root. The report may include IP addresses, referrers, and internal route names.

Step 4: schedule it and validate it

Install the script with limited permissions and schedule it after the previous day's traffic has settled:

sudo install -o deployer -g adm -m 0750 review_nginx_access.py /usr/local/bin/review_nginx_access.py
mkdir -p /var/www/blog/ops-reports
crontab -e
20 8 * * * /usr/bin/python3 /usr/local/bin/review_nginx_access.py > /var/www/blog/ops-reports/nginx-access-$(date +\%F).md 2>&1

Validate with commands instead of assumptions:

ls -lh /var/www/blog/ops-reports/
grep -E "top_404|server_errors|admin_hits|slow" /var/www/blog/ops-reports/nginx-access-$(date +%F).md

If cron does not run, inspect the cron service log for the operating system. If the script works manually but fails under cron, check absolute paths, permissions, the Python path, and the output directory. Cron has a much smaller environment than an interactive shell.

Step 5: turn report lines into maintenance work

A report is only useful when each signal has an owner and a next action. Treat top 404 lines as a triage queue. If the URL is an old article slug, add a 301 redirect to the current article. If it is a missing image or stylesheet, fix the template or upload path. If it is a scan path such as /.env, /wp-admin, or /phpmyadmin, keep the 404 and watch whether the source repeats. Treat 5xx lines as production defects even when there is only one event, because a single failed crawler request can hide a broken template branch.

Slow requests need a separate path. Start with request_time >= 1.0 as the observation threshold and request_time >= 3.0 as the action threshold. For article pages, compare the URI with Flask logs, database query logs, and template rendering changes. For upload routes, check file size, Pillow re-encoding, disk writes, and reverse proxy body limits. For search pages, verify query length, pagination size, and whether the route is accidentally indexed. Change one thing at a time and run the same validation command set after each change.

curl -o /dev/null -s -w '%{http_code} %{time_total}
' https://stepnex.cn/
curl -o /dev/null -s -w '%{http_code} %{time_total}
' https://stepnex.cn/sitemap.xml
curl -o /dev/null -s -w '%{http_code} %{time_total}
' https://stepnex.cn/article/flask-blog-production-health-check-runbook-zh

These commands are not a benchmark. They are a smoke test that confirms the public route still responds after a configuration or code change. Keep the exact command output in the operations note when a fix is made, together with the old configuration, the new configuration, the reload time, and the rollback command.

Pitfalls to avoid

The first pitfall is treating crawler visits as indexing proof. A Baidu or Google crawler line only proves that a request happened; it does not prove that the URL is indexed or ranked. The second pitfall is redirecting every 404 to the home page. That hides useful signals. Old articles with changed slugs should receive 301 redirects, intentionally removed pages can stay 404 or 410, and obvious scan paths should remain 404. The third pitfall is reading Nginx request_time as pure Flask execution time. It includes more than Python code, so compare it with Gunicorn or application logs before changing handlers.

Also avoid making the report public. A daily access report can reveal admin paths, source IPs, referrers, and attack attempts. Keep it behind SSH or an authenticated admin page. If the report is shared with other people, aggregate IPs or remove them.

Review checklist

  • nginx -t passes and reload does not interrupt the site.
  • Home, category, article, English article, sitemap, and robots URLs produce expected status codes in the log.
  • A deliberate missing URL appears in the top 404 section.
  • 5xx and slow request sections include enough data to return to application logs.
  • Baidu and Google crawler counts are tracked without being treated as indexing proof.
  • Admin and publishing endpoints are counted separately.
  • logrotate dry-run succeeds and the active log continues after rotation.
  • Reports are stored outside public access or behind authentication.

Weekly maintenance routine

Review the report once a week and fix issues in priority order: real 5xx responses, high-frequency 404s for valid old content, sitemap or robots errors, slow article pages, and suspicious admin traffic. Record each action in a small operations note: what was observed, what changed, how it was validated, and whether rollback is needed. If a missing URL appears for several days and maps to real content, add a 301 redirect. If a route is slow, compare Nginx timing with application logs before optimizing. If a crawler is noisy, prefer targeted rate limiting over broad blocks. This keeps log review practical: one short report, a few concrete fixes, and a clear trail for the next review.