feat: 添加结构化日志支持并优化错误处理

- 在 netlify/functions/_shared/server-logger.js 中重构日志函数,统一日志输出格式,支持 context 字段覆盖基础字段(如 message)
- 在 netlify/functions/_shared/middleware.js 中增强 withAuth 中间件,注入结构化 logger 到上下文,增加 request_start / request_end / request_error 日志追踪,并记录请求耗时
- 为所有 Netlify Function(giffgaff-sms-activate、giffgaff-graphql、auto-activate-esim、verify-cookie、giffgaff-token-exchange、giffgaff-mfa-challenge、giffgaff-mfa-validation、health)添加结构化日志,替换原有 console 输出,包含请求入参、状态和耗时
- 在 netlify/edge-functions/bff-proxy.js 和 markdown-negotiation.js 中实现 Deno 内联结构化日志,生成 requestId 并统一日志输出格式,支持 INFO/WARN/ERROR/DEBUG 级别控制
- 更新测试用例 tests/modules/server-logger.test.js 和 tests/functions/middleware-logging.test.js,新增日志级别过滤、context 覆盖、多实例隔离、OPTIONS 预检处理等测试场景
This commit is contained in:
Abner
2026-06-26 21:28:21 +08:00
parent 89bacd46ed
commit 574506bcbf
14 changed files with 402 additions and 55 deletions

View File

@@ -131,14 +131,14 @@ describe('withAuth 日志集成', () => {
{ httpMethod: 'GET', path: '/a', headers: { origin: 'https://esim.cosr.eu.org' } },
{ functionName: 'fn1' }
);
const reqId1 = mockLogs[0]?.ctx?.requestId;
const reqId1 = mockLogs[0]?.reqId;
mockLogs.length = 0;
await wrapped(
{ httpMethod: 'GET', path: '/b', headers: { origin: 'https://esim.cosr.eu.org' } },
{ functionName: 'fn2' }
);
const reqId2 = mockLogs[0]?.ctx?.requestId;
const reqId2 = mockLogs[0]?.reqId;
expect(reqId1).toBeDefined();
expect(reqId2).toBeDefined();
@@ -156,4 +156,97 @@ describe('withAuth 日志集成', () => {
expect(createLogger).toHaveBeenCalledWith('my-function', expect.any(String));
});
it('CORS 鉴权失败时应该输出 request_error 日志', async () => {
const mockHandler = jest.fn(async () => ({ statusCode: 200, body: '{}' }));
const wrapped = withAuth(mockHandler, { requireAuth: false });
await wrapped(
{ httpMethod: 'GET', path: '/bff/test', headers: { origin: 'https://evil.com' } },
{ functionName: 'test-fn' }
);
const errorLog = mockLogs.find((l) => l.msg === 'request_error');
expect(errorLog).toBeDefined();
expect(errorLog.ctx.status).toBe(403);
expect(errorLog.ctx.errorName).toBe('AuthError');
// handler 不应被调用
expect(mockHandler).not.toHaveBeenCalled();
});
it('无效 JSON body 时应该输出 request_error 日志', async () => {
const mockHandler = jest.fn(async () => ({ statusCode: 200, body: '{}' }));
const wrapped = withAuth(mockHandler, { requireAuth: false });
await wrapped(
{
httpMethod: 'POST',
path: '/bff/test',
headers: { origin: 'https://esim.cosr.eu.org' },
body: 'not-valid-json{{{',
},
{ functionName: 'test-fn' }
);
const errorLog = mockLogs.find((l) => l.msg === 'request_error');
expect(errorLog).toBeDefined();
expect(errorLog.ctx.status).toBe(400);
expect(errorLog.ctx.errorMessage).toBe('Invalid JSON body');
expect(mockHandler).not.toHaveBeenCalled();
});
it('输入验证失败时应该输出 request_error 日志', async () => {
const mockHandler = jest.fn(async () => ({ statusCode: 200, body: '{}' }));
const schema = { name: { required: true, type: 'string' } };
const wrapped = withAuth(mockHandler, { requireAuth: false, validateSchema: schema });
await wrapped(
{
httpMethod: 'POST',
path: '/bff/test',
headers: { origin: 'https://esim.cosr.eu.org' },
body: JSON.stringify({}),
},
{ functionName: 'test-fn' }
);
const errorLog = mockLogs.find((l) => l.msg === 'request_error');
expect(errorLog).toBeDefined();
expect(errorLog.ctx.status).toBe(400);
expect(errorLog.ctx.errorMessage).toMatch(/name is required/);
expect(mockHandler).not.toHaveBeenCalled();
});
it('error 日志应包含耗时字段', async () => {
const mockHandler = jest.fn(async () => {
throw new Error('slow fail');
});
const wrapped = withAuth(mockHandler, { requireAuth: false });
await wrapped(
{ httpMethod: 'GET', path: '/bff/test', headers: { origin: 'https://esim.cosr.eu.org' } },
{ functionName: 'test-fn' }
);
const errorLog = mockLogs.find((l) => l.msg === 'request_error');
expect(errorLog.ctx.duration).toBeDefined();
expect(typeof errorLog.ctx.duration).toBe('number');
expect(errorLog.ctx.duration).toBeGreaterThanOrEqual(0);
});
it('OPTIONS 预检请求不输出 request_start 日志(提前返回)', async () => {
const mockHandler = jest.fn(async () => ({ statusCode: 200, body: '{}' }));
const wrapped = withAuth(mockHandler, { requireAuth: false });
await wrapped(
{ httpMethod: 'OPTIONS', path: '/bff/test', headers: { origin: 'https://esim.cosr.eu.org' } },
{ functionName: 'test-fn' }
);
// request_start 仍然会输出(在鉴权之前),但 request_end 不会(预检提前返回)
const startLog = mockLogs.find((l) => l.msg === 'request_start');
expect(startLog).toBeDefined();
// handler 不应被调用
expect(mockHandler).not.toHaveBeenCalled();
});
});

View File

@@ -51,6 +51,25 @@ describe('server-logger', () => {
expect(output.duration).toBe(42);
});
it('context 为 null 或 undefined 时不应报错', () => {
const logger = createLogger('test-fn', 'req-1');
expect(() => logger.info('msg', null)).not.toThrow();
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');
});
it('warn 应该输出到 console.warn', () => {
const logger = createLogger('test-fn', 'req-1');
logger.warn('warning msg');
@@ -88,6 +107,50 @@ describe('server-logger', () => {
if (originalLevel) process.env.LOG_LEVEL = originalLevel;
else delete process.env.LOG_LEVEL;
});
it('LOG_LEVEL=ERROR 时应该抑制 INFO 和 WARN', () => {
const originalLevel = process.env.LOG_LEVEL;
process.env.LOG_LEVEL = 'ERROR';
const logger = createLogger('test-fn', 'req-1');
logger.info('should not appear');
logger.warn('should not appear');
expect(consoleSpy.log).not.toHaveBeenCalled();
expect(consoleSpy.warn).not.toHaveBeenCalled();
// ERROR 仍然输出
logger.error('should appear');
expect(consoleSpy.error).toHaveBeenCalledTimes(1);
if (originalLevel) process.env.LOG_LEVEL = originalLevel;
else delete process.env.LOG_LEVEL;
});
it('LOG_LEVEL=WARN 时应该抑制 INFO 和 DEBUG', () => {
const originalLevel = process.env.LOG_LEVEL;
process.env.LOG_LEVEL = 'WARN';
const logger = createLogger('test-fn', 'req-1');
logger.info('should not appear');
logger.debug('should not appear');
expect(consoleSpy.log).not.toHaveBeenCalled();
// WARN 和 ERROR 仍然输出
logger.warn('should appear');
logger.error('should appear');
expect(consoleSpy.warn).toHaveBeenCalledTimes(1);
expect(consoleSpy.error).toHaveBeenCalledTimes(1);
if (originalLevel) process.env.LOG_LEVEL = originalLevel;
else delete process.env.LOG_LEVEL;
});
it('多个 logger 实例应该互不干扰', () => {
const logger1 = createLogger('fn-a', 'req-1');
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');
});
});
describe('parseLogLevel', () => {