diff --git a/packages/core/src/middleware/koa-log.test.ts b/packages/core/src/middleware/koa-log.test.ts index b4a2b2587..af551f0fb 100644 --- a/packages/core/src/middleware/koa-log.test.ts +++ b/packages/core/src/middleware/koa-log.test.ts @@ -19,55 +19,30 @@ jest.mock('nanoid', () => ({ describe('koaLog middleware', () => { const insertLogMock = insertLog as jest.Mock; - const next = jest.fn(); - const type = 'SignInUsernamePassword'; const payload: LogPayload = { userId: 'foo', username: 'Bar', }; + const ip = '192.168.0.1'; + const userAgent = + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/99.0.4844.51 Safari/537.36'; + afterEach(() => { jest.clearAllMocks(); }); it('insert log with success response', async () => { const ctx: WithLogContext> = { - ...createContextWithRouteParameters(), + ...createContextWithRouteParameters({ headers: { 'user-agent': userAgent } }), log, // Bypass middleware context type assert }; + ctx.request.ip = ip; - next.mockImplementationOnce(async () => { + const next = async () => { ctx.log(type, payload); - }); - - await koaLog()(ctx, next); - - expect(insertLogMock).toBeCalledWith({ - id: nanoIdMock, - type, - payload: { - ...payload, - result: LogResult.Success, - }, - }); - }); - - it('should not block request if insertLog throws error', async () => { - const ctx: WithLogContext> = { - ...createContextWithRouteParameters(), - log, // Bypass middleware context type assert - }; - - const error = new Error('Failed to insert log'); - insertLogMock.mockImplementationOnce(async () => { - throw error; - }); - - next.mockImplementationOnce(async () => { - ctx.log(type, payload); - }); - + }; await koaLog()(ctx, next); expect(insertLogMock).toBeCalledWith({ @@ -76,23 +51,25 @@ describe('koaLog middleware', () => { payload: { ...payload, result: LogResult.Success, + ip, + userAgent, }, }); }); it('should insert log with failed result if next throws error', async () => { const ctx: WithLogContext> = { - ...createContextWithRouteParameters(), + ...createContextWithRouteParameters({ headers: { 'user-agent': userAgent } }), log, // Bypass middleware context type assert }; + ctx.request.ip = ip; const error = new Error('next error'); - next.mockImplementationOnce(async () => { + const next = async () => { ctx.log(type, payload); throw error; - }); - + }; await expect(koaLog()(ctx, next)).rejects.toMatchError(error); expect(insertLogMock).toBeCalledWith({ @@ -102,6 +79,8 @@ describe('koaLog middleware', () => { ...payload, result: LogResult.Error, error: String(error), + ip, + userAgent, }, }); }); diff --git a/packages/core/src/middleware/koa-log.ts b/packages/core/src/middleware/koa-log.ts index 7909f52fe..6eceee2b2 100644 --- a/packages/core/src/middleware/koa-log.ts +++ b/packages/core/src/middleware/koa-log.ts @@ -1,27 +1,64 @@ -import { LogPayload, LogPayloads, LogResult, LogType } from '@logto/schemas'; -import { Optional } from '@silverhand/essentials'; +import { BaseLogPayload, LogPayload, LogPayloads, LogResult, LogType } from '@logto/schemas'; import deepmerge from 'deepmerge'; import { MiddlewareType } from 'koa'; import { nanoid } from 'nanoid'; import { insertLog } from '@/queries/log'; -export type WithLogContext = ContextT & { - log: (type: T, payload: LogPayloads[T]) => void; +type MergeLog = (type: T, payload: LogPayloads[T]) => void; + +export type WithLogContext = ContextT & { log: MergeLog }; + +type Logger = { + type?: LogType; + basePayload?: BaseLogPayload; + payload: LogPayload; + set: (basePayload: BaseLogPayload) => void; + log: MergeLog; + save: () => Promise; }; -const saveLog = async (type: LogType, payload: LogPayload) => { - try { - await insertLog({ - id: nanoid(), - type, - payload, - }); - } catch (error: unknown) { - console.error('An error occurred while inserting log'); - console.error(error); - } +/* eslint-disable @silverhand/fp/no-mutation */ +const initLogger = (basePayload?: Readonly) => { + const logger: Logger = { + type: undefined, + basePayload, + payload: {}, + set: (basePayload) => { + logger.basePayload = { + ...logger.basePayload, + ...basePayload, + }; + }, + log: (type, payload) => { + if (type !== logger.type) { + logger.type = type; + logger.payload = payload; + + return; + } + + logger.payload = deepmerge(logger.payload, payload); + }, + save: async () => { + if (!logger.type) { + return; + } + + await insertLog({ + id: nanoid(), + type: logger.type, + payload: { + ...logger.basePayload, + ...logger.payload, + }, + }); + }, + }; + + return logger; }; +/* eslint-enable @silverhand/fp/no-mutation */ export default function koaLog(): MiddlewareType< StateT, @@ -29,34 +66,21 @@ export default function koaLog(): MiddlewareTyp ResponseBodyT > { return async (ctx, next) => { - // eslint-disable-next-line @silverhand/fp/no-let - let logType: Optional; - // eslint-disable-next-line @silverhand/fp/no-let - let logPayload: LogPayload = {}; + const { + ip, + headers: { 'user-agent': userAgent }, + } = ctx.request; - ctx.log = (type, payload) => { - if (logType !== type) { - // eslint-disable-next-line @silverhand/fp/no-mutation - logPayload = {}; // Reset payload when type changes - } - - // eslint-disable-next-line @silverhand/fp/no-mutation - logType = type; // Use first initialized log type - // eslint-disable-next-line @silverhand/fp/no-mutation - logPayload = deepmerge(logPayload, payload); - }; + const logger = initLogger({ result: LogResult.Success, ip, userAgent }); + ctx.log = logger.log; try { await next(); - - if (logType) { - await saveLog(logType, { ...logPayload, result: LogResult.Success }); - } } catch (error: unknown) { - if (logType) { - await saveLog(logType, { ...logPayload, result: LogResult.Error, error: String(error) }); - } + logger.set({ result: LogResult.Error, error: String(error) }); throw error; + } finally { + await logger.save(); } }; } diff --git a/packages/schemas/src/types/log.ts b/packages/schemas/src/types/log.ts index 8bb5a21c9..dbb31531a 100644 --- a/packages/schemas/src/types/log.ts +++ b/packages/schemas/src/types/log.ts @@ -5,40 +5,45 @@ export enum LogResult { Error = 'Error', } -interface BaseLogPayload { - sessionId?: string; +export interface BaseLogPayload { result?: LogResult; error?: string; + ip?: string; + userAgent?: string; } -interface RegisterUsernamePasswordLogPayload extends BaseLogPayload { +interface CommonFields { + sessionId?: string; +} + +interface RegisterUsernamePasswordLogPayload extends CommonFields { userId?: string; username?: string; } -interface RegisterEmailSendPasscodeLogPayload extends BaseLogPayload { +interface RegisterEmailSendPasscodeLogPayload extends CommonFields { email?: string; passcode?: Passcode; } -interface RegisterEmailLogPayload extends BaseLogPayload { +interface RegisterEmailLogPayload extends CommonFields { email?: string; code?: string; userId?: string; } -interface RegisterSmsSendPasscodeLogPayload extends BaseLogPayload { +interface RegisterSmsSendPasscodeLogPayload extends CommonFields { phone?: string; passcode?: Passcode; } -interface RegisterSmsLogPayload extends BaseLogPayload { +interface RegisterSmsLogPayload extends CommonFields { phone?: string; code?: string; userId?: string; } -interface RegisterSocialBindLogPayload extends BaseLogPayload { +interface RegisterSocialBindLogPayload extends CommonFields { connectorId?: string; userInfo?: object; userId?: string; @@ -51,34 +56,34 @@ interface RegisterSocialLogPayload extends RegisterSocialBindLogPayload { redirectTo?: string; } -interface SignInUsernamePasswordLogPayload extends BaseLogPayload { +interface SignInUsernamePasswordLogPayload extends CommonFields { userId?: string; username?: string; } -interface SignInEmailSendPasscodeLogPayload extends BaseLogPayload { +interface SignInEmailSendPasscodeLogPayload extends CommonFields { email?: string; passcode?: Passcode; } -interface SignInEmailLogPayload extends BaseLogPayload { +interface SignInEmailLogPayload extends CommonFields { email?: string; code?: string; userId?: string; } -interface SignInSmsSendPasscodeLogPayload extends BaseLogPayload { +interface SignInSmsSendPasscodeLogPayload extends CommonFields { phone?: string; passcode?: Passcode; } -interface SignInSmsLogPayload extends BaseLogPayload { +interface SignInSmsLogPayload extends CommonFields { phone?: string; code?: string; userId?: string; } -interface SignInSocialBindLogPayload extends BaseLogPayload { +interface SignInSocialBindLogPayload extends CommonFields { connectorId?: string; userInfo?: object; userId?: string;