/**
* アプリ共通ロガー。出力は stdout のみ(ファイル化・ローテーションは OS 側)。
*
* mixin() で request_id / data_source を自動付与する理由:
* 呼び出し忘れで相関が切れるのを防ぎ、USE_FIXTURES の解釈を lib/db の export に一本化するため。
*/
import pino from "pino";
import { USE_FIXTURES } from "@/lib/db/use-fixtures";
import { getRequestId } from "@/lib/logger/context";
import type {
ForbiddenFieldsStrippedLogEvent,
LogEvent,
} from "@/lib/logger/events";
import {
enforceForbiddenErrorFieldsPolicy,
enforceForbiddenFieldsPolicy,
} from "@/lib/logger/redact";
import { formatLogTimestamp } from "@/lib/logger/timestamp";
export type LogLevel = "info" | "warn" | "error";
function resolveLevel(): string {
if (process.env.LOG_LEVEL) return process.env.LOG_LEVEL;
return isProduction() ? "info" : "debug";
}
function isProduction(): boolean {
return process.env.NODE_ENV === "production";
}
/**
* pino インスタンスを組み立てる。
* 出力先を差し替えられるようにしてあるのは、スモークテストが本番と
* 同一設定の出力を検証できるようにするため(設定を二重に書かない)。
*/
export function createLogger(destination?: pino.DestinationStream): pino.Logger {
const options: pino.LoggerOptions = {
level: resolveLevel(),
timestamp: () => `,"timestamp":${JSON.stringify(formatLogTimestamp())}`,
formatters: {
/** 数値レベルだと grep しづらいので info/warn/error の文字列にする */
level(label) {
return { level: label };
},
},
mixin() {
return {
request_id: getRequestId() ?? "-",
data_source: USE_FIXTURES ? "fixtures" : "prisma",
};
},
};
return destination ? pino(options, destination) : pino(options);
}
export const logger = createLogger();
/**
* 指定インスタンスへ1行出す実体。
*
* 禁止フィールドの扱いを環境で分けるのは、ログ呼び出しが catch 節の内側から
* 行われるため。本番で例外を投げると元のエラー処理を壊し、ログの機構が
* リクエストを 500 にしてしまう。「ログの失敗でアプリを落とさない」を優先する。
*
* 本番で検出した場合、元のログ(禁止キーを除去したもの・元のレベルのまま)とは
* **別に** warn を1行出す。docs/logging-design.md §10 は「該当キーを取り除いて warn で
* 記録し、ログ呼び出し自体は成功させる」としか定めておらず、**別行にするのは実装側の判断**。
* 分ける理由: 元のログを元のレベルのまま残しつつ、除去が起きた事実を warn で可視化するため。
* 元のレベルを warn へ格上げして1行にまとめる方式だと、元が error のときに
* 「禁止フィールドが渡された」事実が warn として残らないため、常に分離する。
* warn 側には値を一切載せず、キー名(forbidden_keys)だけを記録する。
*/
export function emitTo(
target: pino.Logger,
level: LogLevel,
payload: LogEvent,
err?: unknown
): void {
const production = isProduction();
const { payload: safe, leaked } = enforceForbiddenFieldsPolicy(
payload,
production
);
// err は error レベルのみ出力する。出さない場合は検査もしない(漏れる経路が無い)
const emitsErr = level === "error" && err !== undefined;
const errResult = emitsErr
? enforceForbiddenErrorFieldsPolicy(err, production)
: { err, leaked: [] as string[] };
// 検査を先に済ませてから書く。非本番の例外は1行も出さずに投げる
const allLeaked = [...new Set([...leaked, ...errResult.leaked])];
if (allLeaked.length > 0) {
const strippedEvent: ForbiddenFieldsStrippedLogEvent = {
event: "log.forbidden_fields_stripped",
forbidden_keys: allLeaked,
};
target.warn(strippedEvent);
}
// message / stack に施設名等が混ざりうるため info / warn には err を付けない
if (emitsErr) {
target.error({ ...safe, err: errResult.err });
return;
}
target[level](safe);
}
/**
* allowlist 型のイベントを1行 JSON で出す。
* Error は pino 標準の err シリアライザに渡し、JSON.stringify(error) は使わない。
*/
export function logEvent(
level: "error",
payload: LogEvent,
err?: unknown
): void;
export function logEvent(level: "info" | "warn", payload: LogEvent): void;
export function logEvent(
level: LogLevel,
payload: LogEvent,
err?: unknown
): void {
emitTo(logger, level, payload, err);
}
export {
runWithRequestId,
getRequestId,
withRequestId,
normalizeRequestId,
REQUEST_ID_HEADER,
} from "@/lib/logger/context";
export type { LogEvent } from "@/lib/logger/events";