♻️ refactor: 将日志输出格式从 JSON 重构为人类可读的字符串

- 修改 `server-logger.js` 核心逻辑,将日志输出从 `JSON.stringify` 改为模板字符串拼接,格式变更为 `[LEVEL] [function] [requestId] message | key=value`
- 同步更新 `bff-proxy.js` 和 `markdown-negotiation.js` 中的 Deno 环境内联日志函数,保持 Edge Function 与 Node.js 环境日志格式一致
- 调整 context 序列化方式,使用 `key=value` 空格分隔,无 context 时不输出分隔符 `|`,并移除 `timestamp` 字段
- 更新 `server-logger.test.js` 测试用例,断言日志输出为字符串匹配而非 JSON 解析,并增加单行输出及无 context 时不包含分隔符的验证
This commit is contained in:
Abner
2026-06-26 21:46:31 +08:00
parent 574506bcbf
commit 68e931b901
4 changed files with 74 additions and 64 deletions

View File

@@ -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);

View File

@@ -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) };
}

View File

@@ -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 {

View File

@@ -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');
});
});