SQLiteでクロススタックオブザーバビリティを実現する:Next.js + FastAPIのトレースID連携ログ基盤

SQLiteでクロススタックオブザーバビリティを実現する:Next.js + FastAPIのトレースID連携ログ基盤

ELKやDatadogを導入するほどではない社内ツールで、SQLite(WALモード)にNode.jsとPythonの両方から書き込み、trace_idでクロスプロセスのリクエストトレーシングとFTS5全文検索付きログビューアーを実現した方法を紹介します。
2026.07.25

はじめに

社内向けAI自動化ツール(Next.js BFF + FastAPI バックエンド)を運用する中で、「ユーザーのリクエストがBFFを通ってバックエンドに届き、LLMプロバイダーを呼び出して返ってくるまで」を一気通貫で追跡できるログ基盤が必要になりました。

ただし、ELKスタックやDatadogを導入するほどの規模ではありません。チームで使う社内ツールです。

そこで、SQLite(WALモード)にNode.jsとPythonの両方から書き込み、FTS5で全文検索可能なログビューアーUI付きのログ基盤を構築しました。インフラ追加ゼロで、クロスプロセスのリクエストトレーシングが実現できます。

なお、このツールはクラウドにデプロイするWebアプリケーションではなく、社内のWindowsマシン上でローカル実行されるデスクトップアプリケーションです。Next.jsとFastAPIが同一マシン上で動作するため、SQLiteファイルを共有できるという前提があります。クラウド環境やコンテナ分離された構成では、別のログ収集手段(例: ログ集約サービスやメッセージキュー)を検討してください。

前提・環境

  • フロントエンド/BFF: Next.js 16 (App Router), better-sqlite3
  • バックエンド: FastAPI (Python 3.12), sqlite3 (標準ライブラリ)
  • ログDB: SQLite 3 (WALモード)
  • OS: Windows (デスクトップデプロイ)

アーキテクチャ

cross-stack-observability-sqlite-nextjs-fastapi-architecture

ステップ1: SQLiteスキーマの設計

Node.jsとPythonの両方から同じスキーマに書き込みます。

CREATE TABLE IF NOT EXISTS logs (
  id            INTEGER PRIMARY KEY AUTOINCREMENT,
  ts_unix_ms    INTEGER NOT NULL,
  level         TEXT NOT NULL,       -- DEBUG/INFO/WARN/ERROR
  source        TEXT NOT NULL,       -- "bff" or "backend"
  logger        TEXT,
  message       TEXT NOT NULL,
  trace_id      TEXT,
  request_id    TEXT,
  span_id       TEXT,
  pid           INTEGER,
  host          TEXT,
  route         TEXT,
  method        TEXT,
  status_code   INTEGER,
  duration_ms   INTEGER,
  attrs_json    TEXT                  -- 任意のJSON属性
);

-- クエリ高速化用インデックス
CREATE INDEX IF NOT EXISTS idx_logs_ts ON logs(ts_unix_ms);
CREATE INDEX IF NOT EXISTS idx_logs_level ON logs(level);
CREATE INDEX IF NOT EXISTS idx_logs_source ON logs(source);
CREATE INDEX IF NOT EXISTS idx_logs_trace ON logs(trace_id);

-- 全文検索用FTS5テーブル
CREATE VIRTUAL TABLE IF NOT EXISTS logs_fts USING fts5(
  message, trace_id, logger, route, attrs,
  content='logs', content_rowid='id'
);

-- FTS5自動同期トリガー
CREATE TRIGGER IF NOT EXISTS logs_ai AFTER INSERT ON logs BEGIN
  INSERT INTO logs_fts(rowid, message, trace_id, logger, route, attrs)
  VALUES (new.id, new.message, new.trace_id, new.logger, new.route, new.attrs_json);  -- attrs_jsonをFTS5のattrsカラムにマッピング
END;

設計判断:

  • sourceカラムで「bff」と「backend」を区別。同じテーブルに入れることでtrace_idで横断検索できる
  • attrs_jsonに構造化されない追加情報を格納(JSON文字列)
  • FTS5でメッセージ、trace_id、logger、route、属性を全文検索可能に

WALモードの設定

SQLiteのデフォルトのジャーナルモードでは、書き込み中にデータベース全体がロックされ、他のプロセスからの読み書きがブロックされます。WAL(Write-Ahead Logging)モードは、変更をデータベースファイル本体ではなく別のWALファイルに先行書き込みすることで、読み取りと書き込みを並行実行可能にする仕組みです。書き込みがあっても読み取りはブロックされず、チェックポイント時にWALの内容がデータベース本体に反映されます。

7bfc39dd-8ec4-40e9-a010-5e81679c210d

Node.jsとPythonが同じファイルに並行書き込みするため、WALモードが必須です。

PRAGMA journal_mode=WAL;
PRAGMA synchronous=NORMAL;
PRAGMA busy_timeout=5000;
PRAGMA wal_autocheckpoint=1000;
  • WAL: 書き込みが読み取りをブロックしない(Windows環境でもWALモードは正常に動作しますが、ネットワークドライブ上のSQLiteファイルではWALが使えないため、ローカルディスクに配置してください)
  • busy_timeout=5000: ロック競合時に5秒まで待機
  • synchronous=NORMAL: WALモードではデータ安全性を維持しつつ書き込み高速化

ステップ2: バックエンド(Python)側の実装

トレースIDミドルウェア

すべてのリクエストにtrace_idを付与し、リクエスト完了時にログを書き込みます。

ここで使っているContextVar(Python標準ライブラリcontextvars)は、非同期タスクごとに独立した値を保持できる変数です。FastAPIはリクエストをasyncで並行処理するため、グローバル変数やスレッドローカル変数では他のリクエストのtrace_idと混ざってしまいます。ContextVarを使うと、各リクエストのasyncコンテキストに紐づいた値を安全に読み書きでき、ミドルウェアでセットしたtrace_idをアプリケーション内のどこからでもget_trace_id()で取得できます。

trace_middleware.py
import time
import uuid
from contextvars import ContextVar
from starlette.requests import Request
from starlette.responses import Response

from .db_logger import insert_log  # SQLiteへの書き込み関数

# リクエストスコープでtrace_idを保持するcontextvar
_trace_id_var: ContextVar[str | None] = ContextVar("trace_id", default=None)

def get_trace_id() -> str | None:
    return _trace_id_var.get()

def set_trace_id(value: str | None) -> None:
    _trace_id_var.set(value)

def log_request(request: Request, response, *, started_ms: int) -> None:
    """リクエスト完了時のログを書き込む"""
    insert_log(
        ts_unix_ms=int(time.time() * 1000),
        level="INFO",
        source="backend",
        logger="http",
        message="request",
        trace_id=get_trace_id(),
        route=request.url.path,
        method=request.method,
        status_code=response.status_code,
        duration_ms=max(0, int(time.time() * 1000) - started_ms),
    )

async def trace_id_middleware(request: Request, call_next):
    # BFFから転送されたtrace_idがあればそれを使う
    trace_id = request.headers.get("x-trace-id") or uuid.uuid4().hex
    started_ms = int(time.time() * 1000)
    set_trace_id(trace_id)  # contextvarsに保存

    try:
        response = await call_next(request)
        response.headers["x-trace-id"] = trace_id

        # リクエスト完了ログ
        log_request(request, response, started_ms=started_ms)
        return response
    except Exception as e:
        # 例外発生時もログを残す
        insert_log(
            ts_unix_ms=int(time.time() * 1000),
            level="ERROR",
            source="backend",
            logger="http",
            message="request_exception",
            trace_id=trace_id,
            duration_ms=max(0, int(time.time() * 1000) - started_ms),
            attrs={"error": str(e)},
        )
        raise
    finally:
        set_trace_id(None)

Pythonロギングハンドラー

標準ライブラリのloggingをSQLiteに橋渡しするハンドラーです。

db_logger.py
import logging
import time

_LEVEL_MAP = {
    logging.DEBUG: "DEBUG",
    logging.INFO: "INFO",
    logging.WARNING: "WARN",
    logging.ERROR: "ERROR",
    logging.CRITICAL: "ERROR",
}

class SqliteLogHandler(logging.Handler):
    def __init__(self, *, source: str = "backend"):
        super().__init__()
        self.source = source

    def emit(self, record: logging.LogRecord) -> None:
        try:
            insert_log(
                ts_unix_ms=int(time.time() * 1000),
                level=_LEVEL_MAP.get(record.levelno, record.levelname),
                source=self.source,
                logger=record.name,
                message=record.getMessage(),
                trace_id=get_trace_id(),
                attrs=record.args if isinstance(record.args, dict) else None,
            )
        except Exception:
            self.handleError(record)  # ログ書き込み失敗でアプリを壊さない

self.handleError(record)が重要です。 ログの書き込みに失敗してもアプリケーションの処理を中断しません。ログ基盤がアプリケーションを壊してはいけません。

ステップ3: フロントエンド/BFF(Node.js)側の実装

BFFロガー

bffLogger.ts
import { insertLog } from "./logDb";

export function getOrCreateTraceId(req: NextRequest | { headers: Headers }) {
  return req.headers.get("x-trace-id") ?? crypto.randomUUID();
}

export async function logBff(
  level: "DEBUG" | "INFO" | "WARN" | "ERROR",
  message: string,
  opts: {
    traceId?: string;
    logger?: string;
    route?: string;
    method?: string;
    statusCode?: number;
    durationMs?: number;
    attrs?: Record<string, unknown>;
  } = {},
) {
  await insertLog({
    level,
    source: "bff",
    logger: opts.logger,
    message,
    trace_id: opts.traceId,
    request_id: opts.traceId,
    route: opts.route,
    method: opts.method,
    status_code: opts.statusCode,
    duration_ms: opts.durationMs,
    attrs: opts.attrs,
  });
}

Route Handlerラッパー

すべてのBFF APIルートに自動でログを付与するラッパーです。

withBffLog.ts
type BffHandler = (req: NextRequest, ctx: unknown) => Promise<Response>;

function levelFromStatus(status: number) {
  if (status < 400) return "INFO" as const;
  if (status < 500) return "WARN" as const;
  return "ERROR" as const;
}

function isStreamingResponse(res: Response) {
  return res.headers.get("content-type")?.includes("text/event-stream") ?? false;
}

export function withBffLog(routeLabel: string, handler: BffHandler) {
  return async (req: NextRequest, ctx: unknown) => {
    const started = Date.now();
    const traceId = getOrCreateTraceId(req);

    try {
      const res = await handler(req, ctx);
      const durationMs = Date.now() - started;

      await logBff(levelFromStatus(res.status), "request", {
        traceId,
        logger: `bff:${routeLabel}`,
        route: req.nextUrl.pathname,
        method: req.method,
        statusCode: res.status,
        durationMs,
        attrs: { ok: res.ok, streaming: isStreamingResponse(res) },
      });

      // trace_idをレスポンスヘッダーに付与
      const headers = new Headers(res.headers);
      headers.set("x-trace-id", traceId);
      return new Response(res.body, { status: res.status, headers });
    } catch (e) {
      const msg = e instanceof Error ? e.message : String(e);
      await logBff("ERROR", "request_failed", {
        traceId,
        logger: `bff:${routeLabel}`,
        statusCode: 500,
        durationMs: Date.now() - started,
        attrs: { error: msg },
      });
      return NextResponse.json({ error: msg, traceId }, { status: 500 });
    }
  };
}

使い方:

app/api/chat/stream/route.ts
export const POST = withBffLog("chat-stream", async (req) => {
  // 通常のルートハンドラーロジック
  const res = await fetch("http://127.0.0.1:8765/v1/chat/stream", { ... });
  return new Response(res.body, { headers: { ... } });
});

ステップ4: ログビューアーUI

ログの検索・閲覧用のUIを/<locale>/logsに用意しました。

クエリAPI

app/api/logs/route.ts
// GET /api/logs?from=&to=&levels=&source=&q=&limit=&cursor=
export async function GET(req: NextRequest) {
  const params = req.nextUrl.searchParams;
  const result = await queryLogs({
    from: params.get("from") ? Number(params.get("from")) : undefined,
    to: params.get("to") ? Number(params.get("to")) : undefined,
    levels: params.get("levels")?.split(","),
    source: params.get("source"),
    q: params.get("q"),           // FTS5全文検索
    limit: Number(params.get("limit")) || 50,
    cursor: params.get("cursor"),  // カーソルページネーション
  });
  return NextResponse.json(result, {
    headers: { "cache-control": "no-store" },
  });
}

ログビューアー自体のログは除外します。 /api/logsへのリクエストをログに記録すると、ログを見るたびにログが増えるという無限ループになります。

7日間リテンション

ログが無制限に蓄積されるのを防ぐため、7日間のリテンションを設定します。

export async function pruneOldLogs() {
  const cutoffMs = Date.now() - 7 * 24 * 60 * 60 * 1000;
  db.prepare("DELETE FROM logs WHERE ts_unix_ms < @cutoffMs").run({ cutoffMs });
  db.exec("PRAGMA wal_checkpoint(TRUNCATE)");
  db.exec("VACUUM");
}

PRAGMAはSQLiteの設定コマンドで、データベースの動作パラメータを変更するために使います(標準SQLにはない、SQLite固有の構文です)。

wal_checkpoint(TRUNCATE)は、WALファイルに蓄積された変更をデータベース本体に書き戻し、WALファイルをサイズ0にリセットします。大量のログを削除した後にこれを実行しないと、削除済みのデータがWALファイルに残り続けてディスクを圧迫します。

VACUUMは、DELETEで削除されたレコードが占めていたディスク領域を実際に解放し、データベースファイルを再構築して縮小します。SQLiteではDELETEだけではファイルサイズは減らず、空いた領域が内部的に「空きページ」として残るだけです。ログのプルーニング後にVACUUMを実行することで、不要なディスク消費を防ぎます。

trace_idによるリクエスト追跡の流れ

  1. ブラウザがBFFにリクエストを送る
  2. BFFがx-trace-idヘッダーを生成(crypto.randomUUID()
  3. BFFがバックエンドにリクエスト転送時、x-trace-idヘッダーを付与
  4. バックエンドがx-trace-idを受け取り、contextvarsに保存
  5. バックエンド内のすべてのログにtrace_idが自動付与
  6. バックエンドがレスポンスヘッダーにx-trace-idを返す
  7. BFFがリクエスト完了ログを書き込み(同じtrace_id

ログビューアーでtrace_idを検索すると、1つのリクエストに関連するBFFログとバックエンドログが時系列で並ぶため、問題の切り分けが容易になります。

運用で得られた知見

ログでアプリを壊さない

すべてのログ書き込みは例外をキャッチします。ログ基盤の障害でユーザーのリクエストが失敗してはいけません。

# Python: handleErrorで握りつぶす
except Exception:
    self.handleError(record)
// TypeScript: try-catchで握りつぶす
try {
  await insertLog({ ... });
} catch {
  // ログ書き込み失敗は無視
}

SSEルートのduration_msは「セットアップ時間」

ストリーミングレスポンスの場合、duration_msはストリーム全体の時間ではなく、レスポンスヘッダーが返るまでの時間を記録します。ストリーム完了まで待つとミドルウェアがブロックされるためです。

ログビューアーの自己参照ループを避ける

ログAPIのルートハンドラーはwithBffLogラッパーから除外します。

// ログ関連エンドポイントはwithBffLogで囲まない
// export const GET = withBffLog("logs", handler);  // NG
export async function GET(req: NextRequest) { ... }  // OK

まとめ

要素 実装
ストレージ SQLite (WALモード、同一ファイル)
書き込み Node.js (better-sqlite3) + Python (sqlite3)
トレース連携 x-trace-idヘッダーをBFF→バックエンド間で伝播
全文検索 FTS5 (message, trace_id, logger, route, attrs)
自動ログ BFF: withBffLogラッパー / Backend: トレースミドルウェア
リテンション 7日間、自動プルーニング
UI カーソルページネーション付きログビューアー

ELKやDatadogを導入するまでもない規模のプロジェクトで、SQLite 1ファイルだけでクロスプロセスのリクエストトレーシングと全文検索付きログビューアーが実現できるというのが最大の学びでした。WALモードのおかげでNode.jsとPythonの並行書き込みも問題なく動作しています。

この記事をシェアする

関連記事