Open3

ログ設計の手順

kokoro tobitakokoro tobita

ログ設計でやること

  • 用途・読者・要件を決める
  • ログレベルの定義(RFC5424などを参考に)
  • フォーマット設計(基本構造化ログ)
  • トレーシングとの連携
  • 回収・保管・ローテーション設計

例外設計も合わせて行う。

参考文献

kokoro tobitakokoro tobita

設計手順

用途・読者・要件を決める

  • 用途は複数あり得るのでカテゴリを分ける(アプリ・アクセス・エラー・監査など)。
  • 読者と閲覧手段を決める(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 サイズ基準+世代管理)。
  • 保持期間:監査・セキュリティは長期、アプリ/アクセスは短〜中期(組織の規程に合わせる)。
kokoro tobitakokoro tobita

実装例(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");
}