# Flask エラー処理・ロギング・可観測性ガイド（3.1系）：JSONエラー設計・構造化ログ・リクエストID・Sentry・ヘルスチェック

> Flask 3.1系の本番エラー処理と可観測性を体系化。errorhandler/abort・カスタムHTTPException・解決順序、HTTPException→JSONと共通エラーエンベロープ、dictConfigによる構造化ログ、リクエストID相関、Sentry連携とPIIスクラブ、ALB/ECS向けヘルスチェックまで公式準拠の実コードで解説します。

- 公開日: 2026-06-20
- 著者: 友田 陽大
- タグ: Python, Flask, 可観測性, ロギング, エラーハンドリング, 本番運用, バックエンド
- URL: https://tomodahinata.com/blog/flask-error-handling-logging-observability-guide

## 要点

- 本番のFlaskは例外を黙って500にせず、errorhandler/abortとカスタムHTTPExceptionで意図したJSONエラーを返す。解決順序はコード→クラス階層→最も具体的なハンドラ
- HTTPException→JSONハンドラと汎用Exceptionハンドラ（isinstanceパススルー）、InvalidAPIUsageパターンを組み合わせ、marshmallowの422と同じエンベロープにAPIエラーを統一する
- ロギングはdictConfigでアプリ生成より前に設定する。app.loggerに触れる前に設定しないと既定ハンドラが付く。コンテナではファイルではなくstdout/stderr（12-factor）
- RequestFormatterとbefore_requestでリクエストIDをログに相関させ、JSON構造化ログでCloudWatch等に集約する。Sentryはsentry-sdk[flask]で導入しPII/秘密はスクラブする
- /health（liveness）と/ready（DB疎通を確認するreadiness）をALB/ECS向けに分け、経済産業大臣賞B2B SaaSの本番運用知見を根拠に解説

---

## **導入：本番で一番こわいのは「沈黙する例外」である**

本番の Flask アプリで最初に壊れるのは、機能ではなく**「障害の見え方」**です。ユーザーから「エラーになる」と報告が来る。しかしレスポンスは無味乾燥な `500 Internal Server Error` の HTML、ログには何も相関情報がなく、どのリクエストが・どのユーザーで・どの段で落ちたのかが繋がらない——この「沈黙」が、5 分で終わる調査を 2 時間の地獄に変えます。

Flask は、本番モードでは例外発生時に「ごく簡素なページを表示し、例外をロガーに記録する」だけの賢い既定を持っています（これは公式の明言です）。学習には十分ですが、**REST API の本番運用には、この既定をそのまま使ってはいけません**。API のクライアント（フロントエンド・モバイル・他サービス）が必要とするのは HTML ページではなく、**機械可読な構造化 JSON エラー**であり、調査する側が必要とするのは、**リクエスト ID で相関した構造化ログ**です。

本記事は、[Flask 本番運用ガイド](/blog/flask-production-guide)（ピラー）の §6「エラー処理とロギング」を、本番品質まで深掘りするスポークです。扱うのは次の 3 層です。

1. **エラー処理**：`errorhandler` / `abort` / カスタム `HTTPException`、解決順序、API のための JSON エラー設計。
2. **ロギング**：`dictConfig`、`app.logger`、構造化ログ、リクエスト ID 相関。
3. **可観測性**：Sentry によるエラー集約、PII スクラブ、ヘルスチェック、そしてトレーシングへの橋渡し。

筆者は、**経済産業大臣賞を受賞した B2B SaaS のバックエンドを Python / Flask / SQLAlchemy / PostgreSQL で設計・実装し、ALB → ECS(Fargate) 上で構造化ログ・エラートラッキングとともに本番運用**してきました。ここで示すのは、その実戦で「障害を 5 分で追える」ようにするために必要だった設計だけです。

> 💡 **この記事で扱うバージョン**：**Flask 3.1 系**を前提とします。エラー処理は `werkzeug.exceptions`、ロギングは標準 `logging` がそのまま土台です。コードは公式ドキュメント（flask.palletsprojects.com の stable）のパターンに基づきます。

---

## **1. 既定のエラー挙動：なぜ API は「自分でエラーを設計」しなければならないか**

まず、Flask が何もしないとどうなるかを正確に押さえます。公式の言葉そのままです——「アプリケーションが本番モードで動作していて例外が送出されると、Flask はごく簡素なページを表示し、例外をロガーに記録する」。

これは Web ページとしては妥当な既定です。しかし API では 2 つの問題があります。

- **レスポンスが HTML**：`Content-Type: text/html` の 500 ページが返るので、JSON を期待するクライアントはパースに失敗します。エラーの種類（バリデーション失敗か、リソース不在か、サーバー内部障害か）をクライアントが機械的に判別できません。
- **デバッグモードの罠**：開発時に `debug=True` だと、500 のハンドラは使われず**インタラクティブデバッガが表示**されます。これを本番で晒すと**任意コード実行**につながる重大な脆弱性です（本番では `DEBUG` は必ず無効）。

> ⚠️ **本番で `DEBUG=True` は厳禁**。公式は「デバッグモードでは『500 Internal Server Error』のハンドラは使われず、代わりにインタラクティブデバッガが表示される」と明言しています。デバッガは Werkzeug のコンソールであり、サーバー上で任意の Python を実行できます。本番で晒せば即座に侵害されます。`DEBUG` の扱いは[本番運用ガイド](/blog/flask-production-guide)の設定章を参照してください。

結論はシンプルです。**API では、例外がそのまま 500 HTML になることを許さず、「どの例外を・どの HTTP ステータスで・どんな JSON で返すか」を自分で設計する**。それが本章以降のテーマです。

---

## **2. エラーハンドラの基礎：`errorhandler` / `register_error_handler` / `abort`**

### **2.1 ハンドラを登録する 2 つの方法**

例外やステータスコードに対する処理は、**ハンドラ関数**として登録します。書き方は 2 通りで、機能は同じです。

```python
from werkzeug.exceptions import BadRequest

# 方法 A：デコレータ（局所的で読みやすい）
@app.errorhandler(BadRequest)
def handle_bad_request(e):
    return "bad request!", 400

# 方法 B：register_error_handler（ファクトリ内でまとめて登録しやすい）
app.register_error_handler(400, handle_bad_request)
```

ここで重要な公式の事実が 2 つあります。

- **`HTTPException` のサブクラスと HTTP コードは登録時に互換**です。`BadRequest.code == 400` なので、`@app.errorhandler(BadRequest)` と `@app.errorhandler(400)` は同じ意味になります。
- **ハンドラの戻り値のステータスコードは自動設定されない**。公式は「レスポンスのステータスコードはハンドラのコードに設定されない。ハンドラからレスポンスを返すときは、適切な HTTP ステータスコードを必ず自分で指定すること」と注意しています。つまり `return "bad request!", 400` のように**第 2 戻り値でステータスを明示**しなければ、200 で返ってしまいます。

> ⚠️ **「ステータスコードの付け忘れ」は頻出バグ**です。`@app.errorhandler(404)` の中で `return jsonify(error="not found")` とだけ書くと、**HTTP ステータスは 200** になります。エラーハンドラの戻り値には必ず `, 404` のようにコードを添えてください。アプリケーションファクトリ構成での登録（`register_error_handler` を `create_app` 内で呼ぶ）は[本番運用ガイド](/blog/flask-production-guide)のファクトリ章と整合します。

### **2.2 `abort(code, description)`：ビューから明示的に中断する**

ビュー関数の途中で「ここで 404 を返したい」というとき、`abort()` を使います。`abort()` は対応する `HTTPException` を送出し、登録済みのハンドラに処理を委ねます。公式のパターンです。

```python
from flask import abort, render_template, request


@app.route("/profile")
def user_profile():
    username = request.args.get("username")   # クエリ文字列から取得
    if username is None:
        abort(400)                            # 引数不正 → 400
    user = get_user(username=username)
    if user is None:
        abort(404)                            # 該当ユーザーなし → 404
    return render_template("profile.html", user=user)


@app.errorhandler(404)
def page_not_found(e):
    return render_template("404.html"), 404
```

`abort(404, description="Resource not found")` のように**第 2 引数で説明文を上書き**できます。これは後述の JSON エラーで、クライアントに具体的な理由を伝えるのに役立ちます。

> 💡 ビューの中で「`username` が無ければ 400」「ユーザーが居なければ 404」と早期に `abort` するのは、ガード節（early return）の一形態です。深いネストを避け、正常系のロジックを下にまっすぐ並べられます。`request.args` の取得や `g` を使ったリクエストスコープの値の引き回しは、[アプリケーション/リクエストコンテキスト解説](/blog/flask-application-request-context-g-current-app-guide)で詳しく扱っています。

### **2.3 カスタム `HTTPException` サブクラス：ドメイン固有のエラーを定義する**

標準にないステータス（例：`507 Insufficient Storage`）や、ドメイン固有のエラーを定義したいときは、`HTTPException` を継承します。公式のパターンです。

```python
import werkzeug.exceptions


class InsufficientStorage(werkzeug.exceptions.HTTPException):
    code = 507
    description = "Not enough storage space."


app.register_error_handler(InsufficientStorage, handle_507)

raise InsufficientStorage()
```

`code` と `description` をクラス属性に持たせるだけで、`raise` するだけの自前例外が作れます。業務的に意味のある名前（`InsufficientStorage`）でコードを書けるため、ビュー関数の可読性が上がります。

---

## **3. 解決順序：どのハンドラが選ばれるか（コード → クラス階層 → 最も具体的）**

複数のハンドラを登録したとき、「どれが選ばれるか」は本番の挙動を左右する核心です。公式の規則をそのまま引きます。

> Flask がリクエスト処理中に例外を捕捉すると、まず**コード**で照合される。そのコードのハンドラが無ければ、Flask は**クラス階層**でエラーを照合し、**最も具体的な**ハンドラが選ばれる。ハンドラが一切登録されていなければ、`HTTPException` のサブクラスはそのコードに関する汎用メッセージを表示し、それ以外の例外は汎用の「500 Internal Server Error」に変換される。

これを表で整理します。

| 段階 | 照合対象 | 例 |
| --- | --- | --- |
| ① コード一致 | `@app.errorhandler(404)` のような数値コード | `abort(404)` → コード 404 のハンドラ |
| ② クラス階層 | コード一致が無ければ例外クラスを辿る | `BadRequest`（=`HTTPException` のサブクラス）→ `HTTPException` のハンドラ |
| ③ 最も具体的 | 階層上、より近い（具体的な）ハンドラを優先 | `Exception` と `HTTPException` の両方があれば、`HTTPException` の例外には後者 |
| ④ 未登録時 | `HTTPException` は汎用メッセージ、それ以外は 500 へ | 想定外の例外 → 500 Internal Server Error |

この規則の実用的な含意は 2 つあります。

- **広いハンドラ（`HTTPException` / `Exception`）と狭いハンドラ（`404`）を併存させてよい**。狭い方が優先されるので、「個別に作り込みたいエラー（404）は専用ハンドラ、それ以外は包括ハンドラで JSON 化」という二段構えが組めます。
- **`Exception` ハンドラは `HTTPException` を奪わない**。公式は明言しています——「`HTTPException` と `Exception` の両方にハンドラを登録した場合、`HTTPException` のハンドラの方が具体的なので、`Exception` ハンドラは `HTTPException` サブクラスを処理しない」。この性質が、次章の「想定外の例外だけを 500 として握る」設計を可能にします。

### **3.1 Blueprint と解決順序：404 だけは例外**

Blueprint ごとにハンドラを登録すると、その Blueprint 配下のエラーに対しては**Blueprint のハンドラがアプリ全体のハンドラより優先**されます。ただし重大な例外が 1 つあります。公式の言葉です。

> Blueprint に登録したハンドラはグローバルに登録したものより優先される。**ただし Blueprint は 404 ルーティングエラーを処理できない**。404 はルーティングの段階で発生し、どの Blueprint かが決定される前だからである。

> ⚠️ **「Blueprint で 404 を握ろうとして握れない」は典型的なハマりどころ**です。存在しない URL は、どの Blueprint にも到達する前にルーティング層で 404 になります。したがって**404 ハンドラはアプリレベル（`@app.errorhandler(404)`）に登録**してください。Blueprint レベルのハンドラは、その Blueprint の中で `abort(403)` するような「到達後のエラー」にのみ効きます。Blueprint 構成そのものは[本番運用ガイド](/blog/flask-production-guide)で扱っています。

---

## **4. JSON エラー設計：API のための「一貫したエラーエンベロープ」**

ここからが API 設計の本丸です。目標は、**すべてのエラーが同じ形の JSON で返ること**。クライアントは 1 種類のパース処理だけでエラーを扱えます。

### **4.1 すべての `HTTPException` を JSON に変える**

`abort(404)` や `abort(400)` が返すのは既定で HTML です。これを一括で JSON に変えるのが、公式が示す `HTTPException` ハンドラです。

```python
from flask import json
from werkzeug.exceptions import HTTPException


@app.errorhandler(HTTPException)
def handle_exception(e):
    """すべての HTTP エラーを JSON で返す。"""
    # HTTPException が持つレスポンスをベースに、本文だけ JSON に差し替える
    response = e.get_response()
    response.data = json.dumps({
        "code": e.code,
        "name": e.name,
        "description": e.description,
    })
    response.content_type = "application/json"
    return response
```

`e.get_response()` が返すレスポンスは**ステータスコードがすでに正しく設定済み**なので、ここでは `, e.code` を添える必要がありません（§2.1 の注意は、レスポンスオブジェクトではなくタプルを返す場合の話です）。`code` / `name` / `description` を本文に入れることで、`abort(404, description="...")` の説明文がそのままクライアントに届きます。

### **4.2 想定外の例外も 500 JSON に変える（`isinstance` パススルー）**

`HTTPException` ハンドラは「Flask が知っている HTTP エラー」を捌きます。しかし、`KeyError` や `ZeroDivisionError` のような**想定外の例外**は `HTTPException` ではありません。これらも JSON で返したいなら、`Exception` ハンドラを足します。公式のパターンです。

```python
from werkzeug.exceptions import HTTPException


@app.errorhandler(Exception)
def handle_exception(e):
    # HTTPException はここで握らず、専用ハンドラ(§4.1)に委ねる
    if isinstance(e, HTTPException):
        return e
    # 想定外の例外だけを 500 として扱う
    return render_template("500_generic.html", e=e), 500
```

ここで `if isinstance(e, HTTPException): return e` の**パススルーが要点**です。§3 で見たとおり `HTTPException` ハンドラの方が具体的なので本来 `Exception` ハンドラには `HTTPException` は来ませんが、コードの意図を明示し、将来の登録順変更にも頑健にするため、このガードを入れておくのが定石です。API なら `render_template` の代わりに JSON を返します。

```python
from flask import jsonify
from werkzeug.exceptions import HTTPException


@app.errorhandler(Exception)
def handle_unexpected_exception(e):
    if isinstance(e, HTTPException):
        return e
    # 想定外の例外は詳細を漏らさず、一貫したエンベロープで 500 を返す
    app.logger.exception("unhandled exception")   # スタックトレースはログへ
    return jsonify(code=500, name="Internal Server Error",
                   description="予期しないエラーが発生しました。"), 500
```

> ⚠️ **エラーレスポンスに内部情報を載せない**。スタックトレース・SQL 文・ファイルパスをクライアントに返すのは情報漏洩です。詳細は `app.logger.exception()` で**ログ側に**記録し、クライアントには汎用メッセージだけを返します。秘密や PII を露出させない原則は[セキュリティ実装ガイド](/blog/flask-security-sessions-csrf-secure-cookies-guide)と一貫します。

### **4.3 想定外例外の正体：`InternalServerError` と `original_exception`**

未捕捉の例外が 500 ハンドラに渡るとき、渡される `e` は**元の例外そのものではありません**。公式の重要な事実です。

> Flask 1.1.0 以降、このエラーハンドラには常に `InternalServerError` のインスタンスが渡され、元の未捕捉例外そのものではない。元のエラーは `e.original_exception` で参照できる。

```python
from werkzeug.exceptions import InternalServerError


@app.errorhandler(InternalServerError)
def handle_500(e):
    original = e.original_exception   # 元の例外（KeyError 等）を取り出す
    app.logger.error("internal error: %s", original)
    return jsonify(code=500, name="Internal Server Error"), 500
```

加えて§1 でも触れたとおり、**デバッグモードでは 500 ハンドラは使われずデバッガが出る**ため、500 ハンドラの動作確認は `DEBUG=False` で行う必要があります。

### **4.4 アプリ独自のエラー型：`InvalidAPIUsage` パターン**

「業務的なエラー（在庫不足・利用上限超過など）を、メッセージと任意のペイロード付きで返したい」というとき、`HTTPException` を継承する代わりに、**素の `Exception` を継承した API エラー型**を作る公式パターンが便利です。

```python
from flask import jsonify


class InvalidAPIUsage(Exception):
    status_code = 400

    def __init__(self, message, status_code=None, payload=None):
        super().__init__()
        self.message = message
        if status_code is not None:
            self.status_code = status_code
        self.payload = payload

    def to_dict(self):
        rv = dict(self.payload or ())
        rv["message"] = self.message
        return rv


@app.errorhandler(InvalidAPIUsage)
def invalid_api_usage(e):
    return jsonify(e.to_dict()), e.status_code
```

ビューからは、業務ルール違反を 1 行で表現できます。

```python
@app.route("/api/orders", methods=["POST"])
def create_order():
    if not has_stock():
        raise InvalidAPIUsage("在庫が不足しています。", status_code=409,
                              payload={"reason": "out_of_stock"})
    ...
```

`payload` に任意のキーを足せるので、クライアントが分岐に使える機械可読な情報（`reason` など）を添えられます。`status_code` を可変にしておくことで、400 系も 409 も同じ型で表現できます。

### **4.5 marshmallow の `ValidationError` を同じエンベロープに揃える**

入力バリデーションに marshmallow を使っているなら、その `ValidationError` も**同じエラーエンベロープに統合**すべきです。対になる [marshmallow × Flask × SQLAlchemy で本番 REST API を設計する](/blog/marshmallow-flask-sqlalchemy-rest-api-production-guide) では、`@app.errorhandler(ValidationError)` で `err.messages` を **422（Unprocessable Entity）** に変換するハンドラを示しました。これと本記事のハンドラ群を、**1 つの登録関数にまとめる**のが本番の型です。

```python
# errors.py — エラーハンドラを 1 箇所に集約する
from flask import Flask, jsonify
from marshmallow import ValidationError
from werkzeug.exceptions import HTTPException


def register_error_handlers(app: Flask) -> None:
    @app.errorhandler(ValidationError)
    def handle_validation_error(e: ValidationError):
        # バリデーション失敗 → 422。フィールド別メッセージを返す
        return jsonify(code=422, name="Unprocessable Entity",
                       errors=e.messages), 422

    @app.errorhandler(HTTPException)
    def handle_http_exception(e: HTTPException):
        # 既知の HTTP エラー → JSON 化（abort(404, description=...) 等）
        return jsonify(code=e.code, name=e.name, description=e.description), e.code or 500

    @app.errorhandler(InvalidAPIUsage)
    def handle_invalid_api_usage(e: InvalidAPIUsage):
        # 業務ルール違反 → 任意のステータス＋ペイロード
        return jsonify(code=e.status_code, **e.to_dict()), e.status_code

    @app.errorhandler(Exception)
    def handle_unexpected(e: Exception):
        if isinstance(e, HTTPException):
            return e   # 既知の HTTP エラーは専用ハンドラへ委ねる
        app.logger.exception("unhandled exception")
        return jsonify(code=500, name="Internal Server Error",
                       description="予期しないエラーが発生しました。"), 500
```

これで API のエラーは、出自（HTTP・バリデーション・業務ルール・想定外）に関わらず、`code` / `name` を共通キーとして持つ一貫した JSON になります。クライアントは `code` で機械的に分岐でき、`errors`（422）や `reason`（業務）といった出自固有の情報を必要に応じて読み取れます。

| エラー出自 | 例外 | ステータス | 主なキー |
| --- | --- | --- | --- |
| 入力バリデーション | `ValidationError`（marshmallow） | 422 | `errors`（フィールド別） |
| 既知の HTTP エラー | `HTTPException` / `abort()` | 4xx/5xx | `description` |
| 業務ルール違反 | `InvalidAPIUsage` | 任意（409 等） | `message` / `reason` |
| 想定外 | その他すべての `Exception` | 500 | `description`（汎用） |

> 💡 この `register_error_handlers(app)` を `create_app()` から呼ぶことで、ハンドラ登録がファクトリの一部として一元管理されます。各ルートに `try / except` を散らかさない——これが DRY とテスト容易性の両立です。

---

## **5. ロギングの基礎：`dictConfig` を「アプリ生成より前に」**

エラーを JSON で返せるようになったら、次は**調査するためのログ**です。Flask のロギングは標準 `logging` そのものです。新しい API を覚える必要はありませんが、**設定するタイミング**だけは Flask 固有の鉄則があります。

### **5.1 最重要の鉄則：設定はアプリ生成より前に**

公式の最重要注意です——「`app.logger` がロギング設定**前**にアクセスされると、既定ハンドラが追加される。可能なら、**アプリケーションオブジェクトを生成する前に**ロギングを設定すること」。

正準形は `logging.config.dictConfig` です。公式のテンプレートをそのまま示します。

```python
from logging.config import dictConfig

dictConfig({
    "version": 1,
    "formatters": {"default": {
        "format": "[%(asctime)s] %(levelname)s in %(module)s: %(message)s",
    }},
    "handlers": {"wsgi": {
        "class": "logging.StreamHandler",
        "stream": "ext://flask.logging.wsgi_errors_stream",
        "formatter": "default",
    }},
    "root": {"level": "INFO", "handlers": ["wsgi"]},
})

app = Flask(__name__)   # ← dictConfig の「後」で生成する
```

`stream: ext://flask.logging.wsgi_errors_stream` は Flask 特有の指定で、リクエスト処理中は WSGI サーバーのエラーストリーム（`environ["wsgi.errors"]`）へ、それ以外は `sys.stderr` へ書き出します。

> ⚠️ **`dictConfig` を `create_app` の中（`Flask(__name__)` の後）に書くと手遅れ**になることがあります。ファクトリ構成では、`create_app` を呼ぶ**前**に `dictConfig` を実行するか、ロギング設定をモジュールの import 時（`create_app` 定義より前のトップレベル）に置きます。ファクトリの起動順序は[本番運用ガイド](/blog/flask-production-guide)のファクトリ章と合わせて設計してください。

### **5.2 `app.logger` の使い方とログレベル**

ログ出力は `app.logger` を使います。`%s` 形式の遅延フォーマットを使うのが標準作法です。

```python
app.logger.debug("詳細な診断情報")
app.logger.info("%s logged in successfully", user.username)
app.logger.warning("認証に %d 回失敗", attempts)
app.logger.error("DB への接続に失敗: %s", err)
```

> ⚠️ **ログレベルの落とし穴**。Python の `logging` の既定レベルは通常 `WARNING` です。つまり**設定したレベルより下のログは一切出ません**。`app.logger.info(...)` を仕込んだのに何も出ない、という事故の大半はこれです。`dictConfig` の `root.level`（または該当ロガーのレベル）を `INFO` 以上に明示してください。

### **5.3 既定ハンドラの挙動と、その除去**

ロギングを一切設定しないと、Flask は `app.logger` に `StreamHandler` を 1 つ追加し、リクエスト中は `environ["wsgi.errors"]`（通常 `sys.stderr`）へ、それ以外は `sys.stderr` へ書き出します。自前のハンドラを付けたいのに既定ハンドラが残ると**二重出力**になるため、明示的に外します。

```python
from flask.logging import default_handler

app.logger.removeHandler(default_handler)
```

なお、Werkzeug（開発サーバー）はリクエスト/レスポンスを `werkzeug` ロガーに記録します。本番ではアクセスログは Gunicorn / リバースプロキシ側で扱うことが多く、その設計は[デプロイガイド](/blog/flask-deployment-gunicorn-docker-production-wsgi-guide)で扱います。

---

## **6. 構造化ログとリクエスト ID：ログを「相関できる」状態にする**

`[2026-06-20 10:00:00] INFO in views: order created` のような行ログは、人間が 1 行ずつ読む分には十分ですが、**集約基盤（CloudWatch Logs / Datadog / Loki 等）で機械的に検索・相関する**には不向きです。本番では 2 つの強化を入れます——**(1) リクエスト文脈の注入**と **(2) JSON 構造化**です。

### **6.1 リクエスト文脈をログに注入する（`RequestFormatter`）**

公式は、ログレコードにリクエスト情報（URL・接続元）を注入するカスタム `Formatter` を示しています。要点は `has_request_context()` で**リクエストコンテキストの有無を判定**することです（起動時やバックグラウンドのログはリクエスト外で出るため）。

```python
import logging

from flask import has_request_context, request
from flask.logging import default_handler


class RequestFormatter(logging.Formatter):
    def format(self, record):
        if has_request_context():
            record.url = request.url
            record.remote_addr = request.remote_addr
        else:
            record.url = None
            record.remote_addr = None
        return super().format(record)


formatter = RequestFormatter(
    "[%(asctime)s] %(remote_addr)s requested %(url)s\n"
    "%(levelname)s in %(module)s: %(message)s"
)
default_handler.setFormatter(formatter)
```

`has_request_context()` を介すことで、**リクエスト中でもバックグラウンドでも同じフォーマッタが安全に使える**ようになります。`has_request_context()` とコンテキストの寿命の詳細は[コンテキスト解説](/blog/flask-application-request-context-g-current-app-guide)を参照してください。

### **6.2 リクエスト ID（相関 ID）を発番して全ログに通す**

障害調査で決定的なのが**リクエスト ID（相関 ID）**です。1 リクエストに一意の ID を振り、そのリクエスト中のすべてのログ行に同じ ID を含めれば、**「このリクエストで起きたこと」を ID 一発で串刺し検索**できます。`before_request` で発番し、`g` に載せ、ログレコードへ注入します。

```python
import uuid

from flask import g, has_request_context, request


@app.before_request
def assign_request_id():
    # 上流(ALB 等)が X-Request-ID を付けていれば踏襲、無ければ発番する
    g.request_id = request.headers.get("X-Request-ID", str(uuid.uuid4()))


@app.after_request
def echo_request_id(response):
    # クライアント/上流が突き合わせられるよう、レスポンスにも載せる
    if "request_id" in g:
        response.headers["X-Request-ID"] = g.request_id
    return response
```

これを `RequestFormatter` に組み込みます。

```python
class RequestFormatter(logging.Formatter):
    def format(self, record):
        if has_request_context():
            record.request_id = g.get("request_id", "-")
            record.url = request.url
            record.remote_addr = request.remote_addr
        else:
            record.request_id = "-"
            record.url = None
            record.remote_addr = None
        return super().format(record)
```

> 💡 **`g` はリクエストごとに独立**です。コンテキストローカルとして実装されているため、Gunicorn の複数ワーカー/スレッドが並行処理しても、リクエスト ID が混ざることはありません。`g` の正体（`contextvars` ベースで「コンテキスト内グローバル」）は[コンテキスト解説](/blog/flask-application-request-context-g-current-app-guide)で詳説しています。上流の ALB やリバースプロキシが付ける ID を踏襲する設計は[デプロイガイド](/blog/flask-deployment-gunicorn-docker-production-wsgi-guide)とも噛み合います。

### **6.3 JSON 構造化ログ：集約基盤で検索可能にする**

CloudWatch Logs Insights や Datadog でフィールド単位に検索・集計するには、**ログ自体を JSON 行**にします。リクエスト ID・レベル・モジュールが構造化フィールドになるため、「`request_id = ...` の全ログ」「`level = ERROR` を時系列で」といったクエリが即座に書けます。

```python
import json
import logging

from flask import g, has_request_context, request


class JsonFormatter(logging.Formatter):
    def format(self, record):
        log = {
            "timestamp": self.formatTime(record),
            "level": record.levelname,
            "module": record.module,
            "message": record.getMessage(),
        }
        if has_request_context():
            log["request_id"] = g.get("request_id", "-")
            log["method"] = request.method
            log["path"] = request.path
            log["remote_addr"] = request.remote_addr
        # 例外があればスタックトレースも構造化して載せる
        if record.exc_info:
            log["exc_info"] = self.formatException(record.exc_info)
        return json.dumps(log, ensure_ascii=False)
```

> 💡 **本番ではログをファイルに書かず、stdout/stderr に出す**。コンテナ（ECS/Kubernetes）では 12-factor の原則どおり、**アプリはログをストリームとして標準出力へ吐き、収集はプラットフォーム（CloudWatch Logs / Fluent Bit 等）に任せる**のが定石です。`logging.StreamHandler`（stdout）+ `JsonFormatter` で十分です。
>
> なお、標準ライブラリには `logging.handlers.RotatingFileHandler`（サイズでローテーションするファイルハンドラ）もありますが、**これは Flask のロギングドキュメントの例ではなく標準ライブラリの機能**です。VM 上で直接ファイルに残す構成では選択肢になりますが、コンテナでは前述のとおり stdout を推奨します。

実運用への組み込みは、`dictConfig` の `handlers` に `JsonFormatter` を指すフォーマッタを定義する形になります。これでログは、§4 の JSON エラーレスポンスとは別経路で、**集約基盤に構造化されたまま流れ込みます**。

---

## **7. エラー集約（Sentry）と PII スクラブ**

ログを集約しても、「いつ・どのエラーが・何件・どのリリースで増えたか」を**集計・通知**するには、エラートラッキング基盤が要ります。代表が **Sentry** です。Flask とは公式インテグレーションで繋ぎます。

```bash
pip install "sentry-sdk[flask]"
```

```python
import sentry_sdk
from sentry_sdk.integrations.flask import FlaskIntegration

sentry_sdk.init(
    dsn="https://<key>@<org>.ingest.sentry.io/<project>",   # 環境変数から注入する
    integrations=[FlaskIntegration()],
    traces_sample_rate=0.1,      # トレースは一部サンプリング（コスト最適化）
    send_default_pii=False,      # 既定で PII を送らない（後述）
    environment="production",
)
```

`FlaskIntegration` を入れるだけで、**未捕捉例外が自動的に Sentry へ送られ**、スタックトレース・リクエスト情報・リリースで集約・通知されます。`app.logger.error(...)` 相当のログも取り込めます。

### **7.1 最重要：PII と秘密をスクラブする**

ここが本番で最も注意すべき点です。**エラートラッキング基盤に個人情報（PII）や秘密が流れ込んだ瞬間、その基盤自体が情報漏洩経路になります**。

- `send_default_pii=False` を明示し、Sentry が自動でユーザー情報・Cookie・ボディを拾わないようにする。
- リクエストボディに含まれる連絡先・パスワード・トークンを、送信前フックでマスクする。
- 相関に必要なのは「誰か」ではなく「同じ人か」なので、ユーザー識別子はハッシュ化して使う。

```python
def scrub(event, hint):
    # 機密ヘッダを落とす
    headers = event.get("request", {}).get("headers", {})
    for key in ("Authorization", "Cookie", "X-Api-Key"):
        headers.pop(key, None)
    return event


sentry_sdk.init(
    dsn="...",
    integrations=[FlaskIntegration()],
    send_default_pii=False,
    before_send=scrub,   # 送信直前に PII/秘密をスクラブする
)
```

> ⚠️ このサイト自身のセキュリティ方針とも一貫します——**お問い合わせフォーム等の PII はログにもエラートラッカーにも残さない**。ログ・エラーイベント・スパン属性のいずれにも、個人名や連絡先を入れないでください。秘密情報の取り扱いは[セキュリティ実装ガイド](/blog/flask-security-sessions-csrf-secure-cookies-guide)、テレメトリへの PII 混入の回避は[OpenTelemetry 可観測性ガイド](/blog/opentelemetry-observability-production-tracing-metrics-logs)でそれぞれ詳説しています。

### **7.2 SMTP でエラーをメール通知する（小規模向け）**

Sentry を導入しない小規模構成では、標準ライブラリの `SMTPHandler` で「ERROR 以上をメール送信」する手もあります。**デバッグ時には送らない**（`if not app.debug`）のが作法です。

```python
import logging
from logging.handlers import SMTPHandler

if not app.debug:
    mail_handler = SMTPHandler(
        mailhost="127.0.0.1",
        fromaddr="server-error@example.com",
        toaddrs=["ops@example.com"],
        subject="Application Error",
    )
    mail_handler.setLevel(logging.ERROR)   # ERROR 以上だけ通知
    app.logger.addHandler(mail_handler)
```

メールはノイズが多く規模が大きいと破綻しやすいので、本格運用では Sentry のような集約基盤を推奨します。

---

## **8. ヘルスチェックと、トレーシングへの橋渡し**

可観測性の最後のピースは、**「アプリが生きているか・受け入れ可能か」をロードバランサに伝える**ヘルスチェックです。ALB → ECS(Fargate) 構成では、これが**異常なタスクの自動切り離しと再起動**を駆動します。

### **8.1 liveness と readiness を分ける**

2 種類のチェックを**別エンドポイントに分ける**のが本番の型です。混ぜると、DB の一時的な不調でアプリ全体が殺されるような過剰反応を招きます。

| 種類 | エンドポイント | 意味 | 失敗時の挙動 |
| --- | --- | --- | --- |
| liveness | `/health` | プロセスが生きているか（依存は見ない） | タスクを**再起動**する |
| readiness | `/ready` | トラフィックを受けられるか（DB 等の依存を確認） | LB から**一時的に外す** |

```python
from flask import jsonify
from sqlalchemy import text

from .extensions import db


@app.get("/health")
def health():
    # liveness: 依存を一切叩かず、プロセスの生存だけを返す（軽量・高速）
    return jsonify(status="ok"), 200


@app.get("/ready")
def ready():
    # readiness: DB へ最小クエリを投げ、受け入れ可能かを判定する
    try:
        db.session.execute(text("SELECT 1"))
    except Exception:
        app.logger.exception("readiness check failed: db unreachable")
        return jsonify(status="unavailable", checks={"db": "fail"}), 503
    return jsonify(status="ok", checks={"db": "ok"}), 200
```

> 💡 **liveness は依存を叩かない**のが鉄則です。`/health` で DB を確認してしまうと、DB が一瞬詰まっただけで ALB がアプリのタスクを「死んでいる」と判断して**再起動**し、復旧を妨げることがあります。「再起動で直る障害（プロセス異常）」だけを liveness で、「待てば直る障害（依存の一時不調）」は readiness で受ける——この切り分けが、無用な再起動ループを防ぎます。ALB のヘルスチェックパス設定や ECS のタスク定義との対応は[デプロイガイド](/blog/flask-deployment-gunicorn-docker-production-wsgi-guide)で扱います。

### **8.2 トレーシング・メトリクスへの橋渡し**

ログ・エラー・ヘルスチェックを整えると、次に欲しくなるのが**「1 リクエストが複数サービスをどう通ったか」を線で追うトレース**です。本記事の構造化ログとリクエスト ID は、その入口になります。

- 本記事の **request_id** は、分散トレーシングの **trace_id** に発展します。OpenTelemetry を入れれば、リクエスト ID を自分で発番する代わりに、**標準のコンテキスト伝播**で複数サービスをまたいで自動相関できます。
- ログに `trace_id` / `span_id` を載せれば、**「メトリクスで気づき → トレースで場所を特定 → ログでなぜかを読む」** の 3 シグナル相関が成立します。

この「3 シグナル（トレース・メトリクス・ログ）を相関させる」可観測性の全体設計、計装、サンプリング、PII スクラブの詳細は [OpenTelemetry 本番可観測性ガイド](/blog/opentelemetry-observability-production-tracing-metrics-logs) に集約しています。そして、実際に障害が起きたときに「誰が・どう動き・どう記録するか」の運用面は [インシデント対応 / ポストモーテム / オンコール ガイド](/blog/incident-response-runbook-postmortem-oncall-sre-guide) で扱っています。本記事のエラー JSON とリクエスト ID は、その**調査の起点**になる土台です。

---

## **まとめ：本番の Flask は「沈黙しない」**

Flask の既定は賢いですが、本番 API ではそのまま使えません。エラーを設計し、ログを相関可能にし、障害を集約して初めて、「動いているのに分からない」が消えます。本記事の要点を再掲します。

1. **既定の 500 HTML を許さない**。API ではエラーを JSON で返すよう自分で設計する。本番で `DEBUG=True` はデバッガを晒す重大脆弱性。
2. **`errorhandler` / `abort` / カスタム `HTTPException`** でエラーを宣言的に扱い、解決順序（コード → クラス階層 → 最も具体的）を理解する。**Blueprint は 404 を握れない**ことに注意。
3. **JSON エラーを一貫したエンベロープに統一**する。`HTTPException`→JSON、`Exception`（`isinstance` パススルー）、`InvalidAPIUsage`、そして marshmallow の **`ValidationError`→422** を 1 つの `register_error_handlers` に集約する。
4. **`InternalServerError.original_exception`** で想定外例外の正体を掴み、内部情報はクライアントに漏らさずログへ記録する。
5. **`dictConfig` をアプリ生成より前に**実行する。`app.logger` を触る前に設定しないと既定ハンドラが付く。ログレベル既定 `WARNING` の罠に注意。
6. **`RequestFormatter` + リクエスト ID（`g` + `before_request`）+ JSON 構造化ログ**で、集約基盤から ID 一発で串刺し検索できる状態にする。コンテナでは**ファイルでなく stdout**。
7. **Sentry（`sentry-sdk[flask]`）でエラーを集約**し、`send_default_pii=False` + `before_send` で **PII / 秘密をスクラブ**する。
8. **`/health`（liveness）と `/ready`（readiness, DB 疎通）を分け**、ALB/ECS の自動回復を正しく駆動する。request_id はトレーシングへの橋渡しになる。

### **本番ロギング / エラー設計チェックリスト**

- [ ] API のエラーはすべて JSON で返るか（HTML 500 が漏れていないか）
- [ ] `DEBUG` は本番で確実に無効か（デバッガが晒されていないか）
- [ ] エラーハンドラの戻り値に HTTP ステータスを明示しているか（200 で返っていないか）
- [ ] 404 ハンドラは**アプリレベル**で登録しているか（Blueprint では握れない）
- [ ] `ValidationError`→422 を含め、エラーエンベロープが全出自で一貫しているか
- [ ] 想定外例外の詳細（スタックトレース・SQL・パス）をクライアントに返していないか
- [ ] `dictConfig` を**アプリ生成より前**に実行しているか
- [ ] ログレベルは `INFO` 以上に明示し、出るべきログが出ているか
- [ ] リクエスト ID を発番し、全ログ行とレスポンスヘッダに相関させているか
- [ ] コンテナでログを**stdout/stderr**（JSON 構造化）に出しているか（ファイルに書いていないか）
- [ ] Sentry 等に PII / 秘密が流れていないか（`send_default_pii=False` + スクラブ）
- [ ] `/health`（liveness, 依存を叩かない）と `/ready`（readiness, DB 確認）を分けているか

エラー処理・ロギング・可観測性は、機能ではなく**「障害が起きたときの自分の速度」を決める投資**です。Flask の薄さは、この層を自分で設計する自由——そして責任——を意味します。全体像は[Flask 本番運用ガイド](/blog/flask-production-guide)へ、入力境界の設計は [marshmallow × Flask × SQLAlchemy ガイド](/blog/marshmallow-flask-sqlalchemy-rest-api-production-guide) へ、3 シグナルの可観測性は [OpenTelemetry ガイド](/blog/opentelemetry-observability-production-tracing-metrics-logs) へとつないでください。
