Open3
ログ設計の手順
ログ設計でやること
- 用途・読者・要件を決める
- ログレベルの定義(RFC5424などを参考に)
- フォーマット設計(基本構造化ログ)
- トレーシングとの連携
- 回収・保管・ローテーション設計
※例外設計も合わせて行う。
参考文献
- https://www.dataset.com/blog/the-10-commandments-of-logging/
- https://betterstack.com/community/guides/logging/logging-best-practices/
- https://newrelic.com/jp/blog/best-practices/best-log-management-practices
- https://terasolunaorg.github.io/guideline/current/ja/ArchitectureInDetail/GeneralFuncDetail/Logging.html
- https://nablarch.github.io/docs/LATEST/doc/application_framework/application_framework/libraries/log.html
設計手順
用途・読者・要件を決める
- 用途は複数あり得るのでカテゴリを分ける(アプリ・アクセス・エラー・監査など)。
- 読者と閲覧手段を決める(CLI、Kibana/Grafana、独自UI)。
- 満たすべき要件(例:PCI DSSの監査要件)を先に洗い出す。
例:ログ種別
ログ種別 | やること | 観点 |
---|---|---|
トレースログ | システムイベントを記録する | 可観測性、性能 |
モニタリングログ | 指標(メトリクス)を出力する | 可観測性、可用性 |
リモート呼び出しログ | 外部連携やRPCの問題特定を支援する | 診断容易性 |
監査ログ | 不正や攻撃から回復するための証跡 | セキュリティ |
業務イベントログ | 業務イベントを記録する | 機能要求 |
利用状況統計ログ | システム利用状況を収集する | ユーザビリティ |
障害追跡ログ | 障害や問題の特定を支援する | 診断容易性 |
例:ログの読者の定義
読者 | 見るもの | レベル |
---|---|---|
SRE/DevOps | Error率、p95、外部API失敗率 | warn以上+メトリクス |
開発 | 例外詳細、再現の手掛かり | info/debug |
セキュリティ | ログイン失敗、権限違反、監査 | notice以上+audit |
ビジネス | 業務イベント、KPI | biz events(匿名化済み) |
ログレベルを決める(RFC5424などを参考に)
RFC5424 | 目安(運用) |
---|---|
EMERG/ALERT/CRIT | 直ちに対応すべき致命的障害(アラート発火) |
ERROR | 失敗イベント(要調査) |
WARN | 違和感/リトライで回復など |
NOTICE | 重要だが正常系(フェイルオーバー成功等) |
INFO | 正常動作の記録(起動、停止、主要フロー) |
DEBUG | 開発/詳細調査用 |
※本番は info
以上、詳細は debug
を一時的に有効化するなど。
ログのフォーマットを決める(基本構造化ログ)
共通メタデータ
export type LogLevel =
| "emerg" | "alert" | "crit" | "error" | "warn" | "notice" | "info" | "debug";
export interface LogBase {
ts: string; // ISO8601(UTC推奨)
level: LogLevel;
svc: string; // サービス名
host?: string;
traceId?: string; // リクエスト相関ID
spanId?: string;
userId?: string; // 監査・統計用途なら匿名ID/ハッシュ
req?: { method: string; path: string; status?: number }; // HTTP簡易情報
}
用途別のペイロード例
// アプリケーション(障害調査)
export interface AppLog extends LogBase {
category: "app";
msg: string; // 人間が読める説明
err?: { name: string; message: string; stack?: string };
context?: Record<string, unknown>; // 追加の状況
}
// 監査
export interface AuditLog extends LogBase {
category: "audit";
action: string; // "user.login", "order.update" など
target?: { type: string; id?: string };
ip?: string; // PIIは必要最小限&保存規程に従う
result: "success" | "failure";
}
// 業務イベント(分析)
export interface BizEvent extends LogBase {
category: "biz";
event: string; // "auction.joined" 等
props: Record<string, unknown>;
}
トレーシングと合わせる(OpenTelemetry等)
- traceId をログにも埋め込む(AsyncLocalStorage で伝播、OTelのContextでもOK)。
- HTTP/外部APIは OTel 自動計装+ログ連携で「どのリクエストのどのスパンで起きたか」を素早く辿れる。
ログ回収・保管・ローテーションを決める
- アプリ側は標準出力にJSON → Log agent(Fluent Bit/Vector)で集約。
- ローテーションはプラットフォーム/ログ収集側で実施(ファイル出力する場合は日次 or サイズ基準+世代管理)。
- 保持期間:監査・セキュリティは長期、アプリ/アクセスは短〜中期(組織の規程に合わせる)。
実装例(TS)
loggerの土台(pino 例)
logger.ts
import pino from "pino";
export const logger = pino({
level: process.env.LOG_LEVEL ?? "info",
base: { svc: process.env.SERVICE_NAME ?? "app" },
timestamp: () => `,"ts":"${new Date().toISOString()}"`,
});
リクエスト相関ID
requestContext.ts
import { AsyncLocalStorage } from "node:async_hooks";
export type RequestContext = { traceId: string; userId?: string };
export const ctx = new AsyncLocalStorage<RequestContext>();
// middleware (Express)
import { randomUUID } from "node:crypto";
import type { RequestHandler } from "express";
export const contextMiddleware: RequestHandler = (req, _res, next) => {
const traceId = req.header("x-request-id") ?? randomUUID();
const userId = req.user?.id; // 認証ミドルウェアの結果など
ctx.run({ traceId, userId }, next);
};
// ログ時に取り出すヘルパ
export const withCtx = <T extends object>(fields?: T) => {
const c = ctx.getStore();
return { traceId: c?.traceId, userId: c?.userId, ...fields };
};
HTTPアクセス/リモート呼び出しの記録
// express access log(簡易)
app.use((req, res, next) => {
const start = Date.now();
res.on("finish", () => {
logger.info(withCtx({
category: "access",
req: { method: req.method, path: req.originalUrl, status: res.statusCode },
rt_ms: Date.now() - start,
}), "http_access");
});
next();
});
// 外部API呼び出し
export async function safeFetch(input: RequestInfo, init?: RequestInit) {
const started = Date.now();
const reqDump = { url: typeof input === "string" ? input : input.toString(), method: init?.method ?? "GET" };
try {
const res = await fetch(input, init);
logger.info(withCtx({
category: "remote",
request: reqDump,
response: { status: res.status, ok: res.ok },
rt_ms: Date.now() - started,
}), "remote_call");
return res;
} catch (err: any) {
logger.error(withCtx({
category: "remote",
request: reqDump,
err: { name: err?.name, message: err?.message },
rt_ms: Date.now() - started,
}), "remote_call_failed");
throw err;
}
}
監査ログ(アプリから明示的に出す)
export function audit(action: string, target?: { type: string; id?: string }, result: "success"|"failure" = "success") {
logger.info(withCtx<AuditLog>({
category: "audit",
action,
target,
result,
// ipはAPIゲートウェイやリバースプロキシ側で付与するのが安全
} as any), "audit");
}
障害追跡(未捕捉例外の収集)
process.on("uncaughtException", (err) => {
logger.fatal(withCtx<AppLog>({
category: "app",
msg: "uncaughtException",
err: { name: err.name, message: err.message, stack: err.stack },
} as any));
process.exit(1);
});
process.on("unhandledRejection", (reason: any) => {
logger.fatal(withCtx<AppLog>({
category: "app",
msg: "unhandledRejection",
err: { name: reason?.name ?? "Error", message: String(reason?.message ?? reason) },
} as any));
});
マスキング(PII/機密)
// mask.ts
const patterns = [
{ re: /\b\d{13,19}\b/g, repl: "***card***" }, // カード番号っぽい
{ re: /"password"\s*:\s*".+?"/gi, repl: `"password":"****"` },
];
export const mask = (s: string) => patterns.reduce((t, p) => t.replace(p.re, p.repl), s);
// 使い方(文字列化前に)
logger.info(withCtx({ category: "app", msg: mask(`login attempt user=${user} password=${pw}`) }));
進捗(バッチ/長時間処理)
export function progress(current: number, total: number, label = "job") {
const pct = total ? Math.round((current/total)*1000)/10 : 0; // 1桁%
logger.info(withCtx({ category: "progress", label, current, total, percent: pct }), "progress");
}