From 85e810533b1cc3d050fafbb6e837be6765445322 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=81=8A=E3=81=95=E3=82=80=E3=81=AE=E3=81=B2=E3=81=A8?= <46447427+samunohito@users.noreply.github.com> Date: Fri, 24 Jul 2026 09:28:56 +0900 Subject: [PATCH] =?UTF-8?q?enhance:=20=E6=97=A7Logger=E3=82=92=E6=8B=A1?= =?UTF-8?q?=E5=BC=B5=E3=81=97=E3=81=A6=E6=A7=8B=E9=80=A0=E5=8C=96=E3=83=AD?= =?UTF-8?q?=E3=82=B0=E3=82=92=E7=9B=B4=E6=8E=A5=E5=8F=97=E3=81=91=E5=8F=96?= =?UTF-8?q?=E3=82=8C=E3=82=8B=E3=82=88=E3=81=86=E3=81=AB=20(#17780)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * enhance: 旧Loggerを拡張して構造化ログを直接受け取れるように * fix lint --- packages/backend/src/logger.ts | 71 +++++++++++++--- packages/backend/src/logging/types.ts | 10 ++- packages/backend/test/unit/logging/Logger.ts | 85 ++++++++++++++++++++ 3 files changed, 152 insertions(+), 14 deletions(-) diff --git a/packages/backend/src/logger.ts b/packages/backend/src/logger.ts index 8d58fd88d2..4144c1cf04 100644 --- a/packages/backend/src/logger.ts +++ b/packages/backend/src/logger.ts @@ -5,9 +5,12 @@ import { bindThis } from '@/decorators.js'; import { logManager } from './logging/logging-runtime.js'; -import type { LogLevel, LoggerContext, LogWriteInput } from './logging/types.js'; +import type { LogEntryInput, LogLevel, LoggerContext, LogWriteInput } from './logging/types.js'; import type { Keyword } from 'color-convert'; +// 旧APIのdataは表示用の任意値を受け取り、Errorや配列も既存呼び出しで使用されています。 +type LegacyData = Record | null; + /** * ロガー名の階層と従来の公開APIを提供する薄い窓口です。 * 出力条件の判断や整形はLogManagerとLogBackendへ委譲します。 @@ -52,6 +55,15 @@ export default class Logger { }); } + /** level別メソッドの構造化入力にlevelとLoggerのcontextを付けて渡します。 */ + @bindThis + private logStructured(level: LogLevel, input: LogEntryInput): void { + this.write({ + ...input, + level, + }); + } + /** 構造化ログをLoggerのcontext付きでLogManagerへ渡します。 */ @bindThis public write(input: LogWriteInput): void { @@ -62,24 +74,35 @@ export default class Logger { } /** 処理を継続できない状況を記録します。 */ + public error(input: LogEntryInput): void; + public error(error: Error, data?: LegacyData, important?: boolean): void; + public error(message: string, data?: LegacyData, important?: boolean): void; + public error(errorOrMessage: string | Error, data?: LegacyData, important?: boolean): void; @bindThis - public error(x: string | Error, data?: Record | null, important = false): void { + public error(x: LogEntryInput | string | Error, data?: LegacyData, important = false): void { if (x instanceof Error) { // エラー本体も第2引数へ残し、従来どおりスタックなどを確認できるようにします。 data = data ?? {}; data.e = x; this.log('error', x.toString(), data, important, undefined, x); - } else if (typeof x === 'object') { - this.log('error', `${(x as any).message ?? (x as any).name ?? x}`, data, important); + } else if (typeof x === 'string') { + this.log('error', x, data, important); } else { - this.log('error', `${x}`, data, important); + this.logStructured('error', x); } } /** 処理は継続できるものの、改善が必要な状況を記録します。 */ + public warn(input: LogEntryInput): void; + public warn(message: string): void; + public warn(message: string, data?: LegacyData, important?: boolean): void; @bindThis - public warn(message: string, data?: Record | null, important = false): void { - this.log('warn', message, data, important); + public warn(inputOrMessage: LogEntryInput | string, data?: LegacyData, important = false): void { + if (typeof inputOrMessage === 'string') { + this.log('warn', inputOrMessage, data, important); + } else { + this.logStructured('warn', inputOrMessage); + } } /** 処理が成功したことを、従来のDONE表示で記録します。 */ @@ -89,14 +112,40 @@ export default class Logger { } /** 開発者向けの調査情報を記録します。 */ + public debug(input: LogEntryInput): void; + public debug(message: string): void; + public debug(message: string, data?: LegacyData, important?: boolean): void; @bindThis - public debug(message: string, data?: Record | null, important = false): void { - this.log('debug', message, data, important); + public debug(inputOrMessage: LogEntryInput | string, data?: LegacyData, important = false): void { + if (typeof inputOrMessage === 'string') { + this.log('debug', inputOrMessage, data, important); + } else { + this.logStructured('debug', inputOrMessage); + } } /** 通常の動作状況を記録します。 */ + public info(input: LogEntryInput): void; + public info(message: string): void; + public info(message: string, data?: LegacyData, important?: boolean): void; @bindThis - public info(message: string, data?: Record | null, important = false): void { - this.log('info', message, data, important); + public info(inputOrMessage: LogEntryInput | string, data?: LegacyData, important = false): void { + if (typeof inputOrMessage === 'string') { + this.log('info', inputOrMessage, data, important); + } else { + this.logStructured('info', inputOrMessage); + } + } + + /** 致命的な状況を構造化ログとして記録します。 */ + public fatal(input: LogEntryInput): void; + public fatal(message: string): void; + @bindThis + public fatal(inputOrMessage: LogEntryInput | string): void { + if (typeof inputOrMessage === 'string') { + this.logStructured('fatal', { message: inputOrMessage }); + } else { + this.logStructured('fatal', inputOrMessage); + } } } diff --git a/packages/backend/src/logging/types.ts b/packages/backend/src/logging/types.ts index 7f122364cc..ddd10f410f 100644 --- a/packages/backend/src/logging/types.ts +++ b/packages/backend/src/logging/types.ts @@ -52,15 +52,19 @@ export type LogTraceContext = { /** LogManagerが出力直前に呼び出すTrace Context取得処理です。 */ export type LogTraceContextProvider = () => LogTraceContext | undefined; -/** ロガーの呼び出し側が構造化ログとして指定する入力です。 */ -export type LogWriteInput = { - readonly level: LogLevel; +/** level別のLoggerメソッドが受け取る構造化ログの共通入力です。 */ +export type LogEntryInput = { readonly message: string; readonly eventName?: string; readonly attributes?: Readonly>; readonly error?: unknown; }; +/** 動的なlevelを含めてLogManagerへ渡す構造化ログの入力です。 */ +export type LogWriteInput = LogEntryInput & { + readonly level: LogLevel; +}; + /** * ロガー名を構成する一要素です。 * 色は見やすい形式での表示だけに使い、ログの意味には影響させません。 diff --git a/packages/backend/test/unit/logging/Logger.ts b/packages/backend/test/unit/logging/Logger.ts index 8cadbca592..dca5cab773 100644 --- a/packages/backend/test/unit/logging/Logger.ts +++ b/packages/backend/test/unit/logging/Logger.ts @@ -76,6 +76,91 @@ describe('Logger', () => { })); }); + test('supports structured input through every level-specific method', () => { + const logger = new Logger('root').createSubLogger('child'); + const error = new Error('broken'); + const input = { + eventName: 'example.failed', + message: 'failed', + attributes: { id: 'id' }, + error, + }; + + logger.debug(input); + logger.info(input); + logger.warn(input); + logger.error(input); + logger.fatal(input); + + expect(mocks.write.mock.calls.map(([entry]) => entry.level)).toEqual([ + 'debug', + 'info', + 'warn', + 'error', + 'fatal', + ]); + for (const [entry] of mocks.write.mock.calls) { + expect(entry).toMatchObject({ + ...input, + context: [ + { name: 'root', color: undefined }, + { name: 'child', color: undefined }, + ], + }); + expect(entry).not.toHaveProperty('compatibility'); + } + }); + + test('level-specific methods own the level even for runtime-invalid input', () => { + const logger = new Logger('root'); + + // @ts-expect-error level is selected by the method rather than the input object + logger.warn({ level: 'error', message: 'warning' }); + + expect(mocks.write.mock.calls[0][0]).toMatchObject({ + level: 'warn', + message: 'warning', + }); + }); + + test('preserves the legacy string signatures for non-error levels', () => { + const logger = new Logger('root'); + logger.debug('debug', { source: 'debug' }, true); + logger.info('info', null, true); + logger.warn('warn', { source: 'warn' }); + + expect(mocks.write.mock.calls.map(([entry]) => entry.compatibility)).toEqual([ + { legacyLevel: undefined, important: true, data: { source: 'debug' } }, + { legacyLevel: undefined, important: true, data: null }, + { legacyLevel: undefined, important: false, data: { source: 'warn' } }, + ]); + }); + + test('records a fatal string through the structured API', () => { + new Logger('root').fatal('fatal message'); + + expect(mocks.write).toHaveBeenCalledWith({ + level: 'fatal', + message: 'fatal message', + context: [{ name: 'root', color: undefined }], + }); + }); + + test('preserves the legacy error string signature', () => { + new Logger('root').error('failed', { requestId: 'request' }, true); + + expect(mocks.write).toHaveBeenCalledWith({ + level: 'error', + message: 'failed', + context: [{ name: 'root', color: undefined }], + compatibility: { + legacyLevel: undefined, + important: true, + data: { requestId: 'request' }, + }, + }); + }); + test('uses Error.toString and adds the Error to existing data', () => { const logger = new Logger('root'); const error = new TypeError('broken');