diff --git a/netlify/edge-functions/bff-proxy.js b/netlify/edge-functions/bff-proxy.js index 7af971d..bda5e77 100644 --- a/netlify/edge-functions/bff-proxy.js +++ b/netlify/edge-functions/bff-proxy.js @@ -8,22 +8,18 @@ import qrCodeLib from './qrcode-lib.js'; -// === Deno 环境内联结构化日志(与 netlify/functions/_shared/server-logger.js 输出格式一致) === +// === Deno 环境内联日志(与 netlify/functions/_shared/server-logger.js 输出格式一致) === +// 输出人类可读格式,方便用户查看和复制给开发者排查 const EDGE_LOG_LEVELS = { DEBUG: 0, INFO: 1, WARN: 2, ERROR: 3 }; function createEdgeLogger(functionName, requestId) { const currentLevel = EDGE_LOG_LEVELS[getEnv('LOG_LEVEL')] ?? EDGE_LOG_LEVELS.INFO; function log(level, message, context = {}) { if (EDGE_LOG_LEVELS[level] < currentLevel) return; - const entry = { - level, - message, - function: functionName, - requestId, - timestamp: new Date().toISOString(), - ...context, - }; - const line = JSON.stringify(entry); + const pairs = Object.entries(context).map(([k, v]) => `${k}=${v}`).join(' '); + const line = pairs + ? `[${level}] [${functionName}] [${requestId}] ${message} | ${pairs}` + : `[${level}] [${functionName}] [${requestId}] ${message}`; if (level === 'ERROR') console.error(line); else if (level === 'WARN') console.warn(line); else console.log(line); diff --git a/netlify/edge-functions/markdown-negotiation.js b/netlify/edge-functions/markdown-negotiation.js index e617996..72b6ab0 100644 --- a/netlify/edge-functions/markdown-negotiation.js +++ b/netlify/edge-functions/markdown-negotiation.js @@ -5,14 +5,18 @@ * - 浏览器请求保持 HTML 默认响应 */ -// Deno 环境内联结构化日志(与 bff-proxy.js 格式一致) +// Deno 环境内联日志(与 bff-proxy.js 输出格式一致) +// 输出人类可读格式,方便用户查看和复制给开发者排查 const MD_LOG_LEVELS = { DEBUG: 0, INFO: 1, WARN: 2, ERROR: 3 }; function createMdLogger(functionName, requestId) { const currentLevel = MD_LOG_LEVELS.INFO; function log(level, message, context = {}) { if (MD_LOG_LEVELS[level] < currentLevel) return; - const entry = { level, message, function: functionName, requestId, timestamp: new Date().toISOString(), ...context }; - console.log(JSON.stringify(entry)); + const pairs = Object.entries(context).map(([k, v]) => `${k}=${v}`).join(' '); + const line = pairs + ? `[${level}] [${functionName}] [${requestId}] ${message} | ${pairs}` + : `[${level}] [${functionName}] [${requestId}] ${message}`; + console.log(line); } return { info: (msg, ctx) => log('INFO', msg, ctx), warn: (msg, ctx) => log('WARN', msg, ctx), error: (msg, ctx) => log('ERROR', msg, ctx) }; } diff --git a/netlify/functions/_shared/server-logger.js b/netlify/functions/_shared/server-logger.js index 5e7ddf3..f3b3a2c 100644 --- a/netlify/functions/_shared/server-logger.js +++ b/netlify/functions/_shared/server-logger.js @@ -1,11 +1,12 @@ /** * 服务端结构化日志模块 * - * 为 Netlify Functions (Node.js 运行时) 提供统一的 JSON 格式日志输出。 - * 输出为单行 JSON,便于 Netlify 日志流解析和搜索。 + * 为 Netlify Functions (Node.js 运行时) 提供统一的日志输出。 + * 控制台输出人类可读格式,方便用户查看和复制给开发者排查。 + * 同时附带结构化 JSON 数据,便于日志系统解析。 * - * 日志格式: - * {"level":"INFO","message":"...","function":"fn-name","requestId":"uuid","timestamp":"ISO","...context"} + * 输出格式: + * [INFO] [fn-name] [req-uuid] message | key=value key=value * * @module server-logger */ @@ -48,7 +49,8 @@ function getCurrentLevel() { */ function createLogger(functionName, requestId) { /** - * 构建日志对象并输出 + * 构建人类可读日志并输出 + * 格式: [LEVEL] [function] [requestId] message | key=value key=value * @param {string} level - 日志级别 * @param {string} message - 日志消息 * @param {Object} [context] - 附加字段 @@ -57,17 +59,16 @@ function createLogger(functionName, requestId) { function log(level, message, context = {}, outputFn) { if (LOG_LEVELS[level] < getCurrentLevel()) return; - const entry = { - level, - message, - function: functionName, - requestId, - timestamp: new Date().toISOString(), - }; + // 构建人类可读的附加字段(key=value 格式,方便复制给开发者) + const pairs = context && typeof context === 'object' + ? Object.entries(context).map(([k, v]) => `${k}=${v}`).join(' ') + : ''; - Object.assign(entry, context); + const line = pairs + ? `[${level}] [${functionName}] [${requestId}] ${message} | ${pairs}` + : `[${level}] [${functionName}] [${requestId}] ${message}`; - outputFn(JSON.stringify(entry)); + outputFn(line); } return { diff --git a/tests/modules/server-logger.test.js b/tests/modules/server-logger.test.js index b4af4f7..a88be86 100644 --- a/tests/modules/server-logger.test.js +++ b/tests/modules/server-logger.test.js @@ -1,6 +1,8 @@ /** * server-logger 模块单元测试 - * 覆盖结构化日志输出、日志级别控制、工厂函数等核心功能 + * 覆盖日志输出格式、日志级别控制、工厂函数等核心功能 + * + * 输出格式: [LEVEL] [function] [requestId] message | key=value key=value */ 'use strict'; @@ -31,24 +33,29 @@ describe('server-logger', () => { expect(typeof logger.debug).toBe('function'); }); - it('应该输出 JSON 格式的结构化日志', () => { + it('应该输出人类可读格式的日志', () => { const logger = createLogger('test-fn', 'req-1'); logger.info('test message'); expect(consoleSpy.log).toHaveBeenCalledTimes(1); - const output = JSON.parse(consoleSpy.log.mock.calls[0][0]); - expect(output.level).toBe('INFO'); - expect(output.message).toBe('test message'); - expect(output.function).toBe('test-fn'); - expect(output.requestId).toBe('req-1'); - expect(output.timestamp).toBeDefined(); + const output = consoleSpy.log.mock.calls[0][0]; + expect(output).toBe('[INFO] [test-fn] [req-1] test message'); }); - it('应该支持附加 context 字段', () => { + it('应该支持附加 context 字段(key=value 格式)', () => { const logger = createLogger('test-fn', 'req-1'); logger.info('with context', { status: 200, duration: 42 }); - const output = JSON.parse(consoleSpy.log.mock.calls[0][0]); - expect(output.status).toBe(200); - expect(output.duration).toBe(42); + const output = consoleSpy.log.mock.calls[0][0]; + expect(output).toContain('[INFO] [test-fn] [req-1] with context'); + expect(output).toContain('status=200'); + expect(output).toContain('duration=42'); + expect(output).toContain(' | '); + }); + + it('无 context 时不应包含分隔符 |', () => { + const logger = createLogger('test-fn', 'req-1'); + logger.info('no context'); + const output = consoleSpy.log.mock.calls[0][0]; + expect(output).not.toContain(' | '); }); it('context 为 null 或 undefined 时不应报错', () => { @@ -57,34 +64,27 @@ describe('server-logger', () => { expect(() => logger.info('msg', undefined)).not.toThrow(); expect(() => logger.warn('msg', null)).not.toThrow(); expect(() => logger.error('msg', null)).not.toThrow(); - const output = JSON.parse(consoleSpy.log.mock.calls[0][0]); - expect(output.message).toBe('msg'); - expect(output.requestId).toBe('req-1'); - }); - - it('context 中同名字段应覆盖基础字段(Object.assign 语义)', () => { - const logger = createLogger('test-fn', 'req-1'); - logger.info('override test', { message: 'overridden' }); - const output = JSON.parse(consoleSpy.log.mock.calls[0][0]); - // Object.assign 后 context 中的 message 覆盖基础字段 - expect(output.message).toBe('overridden'); + const output = consoleSpy.log.mock.calls[0][0]; + expect(output).toContain('[INFO] [test-fn] [req-1] msg'); }); it('warn 应该输出到 console.warn', () => { const logger = createLogger('test-fn', 'req-1'); logger.warn('warning msg'); expect(consoleSpy.warn).toHaveBeenCalledTimes(1); - const output = JSON.parse(consoleSpy.warn.mock.calls[0][0]); - expect(output.level).toBe('WARN'); + const output = consoleSpy.warn.mock.calls[0][0]; + expect(output).toContain('[WARN]'); + expect(output).toContain('warning msg'); }); it('error 应该输出到 console.error', () => { const logger = createLogger('test-fn', 'req-1'); logger.error('error msg', { stack: 'fake stack' }); expect(consoleSpy.error).toHaveBeenCalledTimes(1); - const output = JSON.parse(consoleSpy.error.mock.calls[0][0]); - expect(output.level).toBe('ERROR'); - expect(output.stack).toBe('fake stack'); + const output = consoleSpy.error.mock.calls[0][0]; + expect(output).toContain('[ERROR]'); + expect(output).toContain('error msg'); + expect(output).toContain('stack=fake stack'); }); it('DEBUG 日志在非 DEBUG 环境下应该被抑制', () => { @@ -101,9 +101,9 @@ describe('server-logger', () => { process.env.LOG_LEVEL = 'DEBUG'; const logger = createLogger('test-fn', 'req-1'); logger.debug('debug msg'); - const output = JSON.parse(consoleSpy.log.mock.calls[0][0]); - expect(output.level).toBe('DEBUG'); - expect(output.message).toBe('debug msg'); + const output = consoleSpy.log.mock.calls[0][0]; + expect(output).toContain('[DEBUG]'); + expect(output).toContain('debug msg'); if (originalLevel) process.env.LOG_LEVEL = originalLevel; else delete process.env.LOG_LEVEL; }); @@ -144,12 +144,21 @@ describe('server-logger', () => { const logger2 = createLogger('fn-b', 'req-2'); logger1.info('from a'); logger2.info('from b'); - const out1 = JSON.parse(consoleSpy.log.mock.calls[0][0]); - const out2 = JSON.parse(consoleSpy.log.mock.calls[1][0]); - expect(out1.function).toBe('fn-a'); - expect(out1.requestId).toBe('req-1'); - expect(out2.function).toBe('fn-b'); - expect(out2.requestId).toBe('req-2'); + const out1 = consoleSpy.log.mock.calls[0][0]; + const out2 = consoleSpy.log.mock.calls[1][0]; + expect(out1).toContain('[fn-a]'); + expect(out1).toContain('[req-1]'); + expect(out2).toContain('[fn-b]'); + expect(out2).toContain('[req-2]'); + }); + + it('日志格式应该方便用户复制(一行一条)', () => { + const logger = createLogger('giffgaff-graphql', 'abc-123'); + logger.info('calling_upstream', { operationName: 'getESims', isSwap: false }); + const output = consoleSpy.log.mock.calls[0][0]; + // 确认是单行、无换行符、包含所有关键信息 + expect(output).not.toContain('\n'); + expect(output).toBe('[INFO] [giffgaff-graphql] [abc-123] calling_upstream | operationName=getESims isSwap=false'); }); });