
SQLiteでクロススタックオブザーバビリティを実現する:Next.js + FastAPIのトレースID連携ログ基盤
はじめに
社内向け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 (デスクトップデプロイ)
アーキテクチャ

ステップ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の内容がデータベース本体に反映されます。

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()で取得できます。
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に橋渡しするハンドラーです。
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ロガー
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ルートに自動でログを付与するラッパーです。
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 });
}
};
}
使い方:
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
// 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によるリクエスト追跡の流れ
- ブラウザがBFFにリクエストを送る
- BFFが
x-trace-idヘッダーを生成(crypto.randomUUID()) - BFFがバックエンドにリクエスト転送時、
x-trace-idヘッダーを付与 - バックエンドが
x-trace-idを受け取り、contextvarsに保存 - バックエンド内のすべてのログに
trace_idが自動付与 - バックエンドがレスポンスヘッダーに
x-trace-idを返す - 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の並行書き込みも問題なく動作しています。







