mirror of
https://github.com/Silentely/eSIM-Tools.git
synced 2026-09-03 06:24:20 +08:00
✨ 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:
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
Reference in New Issue
Block a user