diff --git a/packages/shared/src/hooks/logging-hook.ts b/packages/shared/src/hooks/logging-hook.ts new file mode 100644 index 000000000..4511e8baf --- /dev/null +++ b/packages/shared/src/hooks/logging-hook.ts @@ -0,0 +1,73 @@ +import type { OpenFeatureError } from '../errors'; +import type { BaseHook } from './hook'; +import type { BeforeHookContext, HookContext, HookHints } from './hooks'; +import type { FlagValue, EvaluationDetails } from '../evaluation'; +import type { Logger } from '../logger'; +import { DefaultLogger, LogLevel, SafeLogger } from '../logger'; + + +type LoggerPayload = Record; + +const DOMAIN_KEY = 'domain'; +const PROVIDER_NAME_KEY = 'provider_name'; +const FLAG_KEY_KEY = 'flag_key'; +const DEFAULT_VALUE_KEY = 'default_value'; +const EVALUATION_CONTEXT_KEY = 'evaluation_context'; +const ERROR_CODE_KEY = 'error_code'; +const ERROR_MESSAGE_KEY = 'error_message'; +const REASON_KEY = 'reason'; +const VARIANT_KEY = 'variant'; +const VALUE_KEY = 'value'; + +export class LoggingHook implements BaseHook { + readonly includeEvaluationContext: boolean = false; + readonly logger: Logger; + + constructor(includeEvaluationContext: boolean = false, logger?: Logger) { + this.includeEvaluationContext = !!includeEvaluationContext; + this.logger = logger || new SafeLogger(new DefaultLogger(LogLevel.DEBUG)); + } + + before(hookContext: BeforeHookContext): void { + const payload: LoggerPayload = { stage: 'before' }; + this.addCommonProps(payload, hookContext); + this.logger.debug(payload); + } + + after(hookContext: Readonly>, evaluationDetails: EvaluationDetails): void { + const payload: LoggerPayload = { stage: 'after' }; + + payload[REASON_KEY] = evaluationDetails.reason; + payload[VARIANT_KEY] = evaluationDetails.variant; + payload[VALUE_KEY] = evaluationDetails.value; + + this.addCommonProps(payload, hookContext); + this.logger.debug(payload); + } + + error(hookContext: Readonly>, error: OpenFeatureError): void { + const payload: LoggerPayload = { stage: 'error' }; + + payload[ERROR_MESSAGE_KEY] = error.message; + payload[ERROR_CODE_KEY] = error.code; + + this.addCommonProps(payload, hookContext); + this.logger.error(payload); + } + + + finally(hookContext: Readonly>, evaluationDetails: EvaluationDetails, hookHints?: HookHints): void { + this.logger.info(hookContext, hookHints); + } + + private addCommonProps(payload: LoggerPayload, hookContext: HookContext): void { + payload[DOMAIN_KEY] = hookContext.clientMetadata.domain; + payload[PROVIDER_NAME_KEY] = hookContext.providerMetadata.name; + payload[FLAG_KEY_KEY] = hookContext.flagKey; + payload[DEFAULT_VALUE_KEY] = hookContext.defaultValue; + + if (this.includeEvaluationContext) { + payload[EVALUATION_CONTEXT_KEY] = hookContext.context; + } + } +} diff --git a/packages/shared/src/logger/default-logger.ts b/packages/shared/src/logger/default-logger.ts index 111ef6125..62be655a1 100644 --- a/packages/shared/src/logger/default-logger.ts +++ b/packages/shared/src/logger/default-logger.ts @@ -1,17 +1,37 @@ /* eslint-disable @typescript-eslint/no-empty-function */ -import type { Logger } from './logger'; +import type { Logger} from './logger'; +import { LogLevel } from './logger'; export class DefaultLogger implements Logger { + + private readonly logLevel : LogLevel; + + constructor(logLevel: LogLevel = LogLevel.WARN){ + this.logLevel = logLevel; + } + error(...args: unknown[]): void { - console.error(...args); + if(this.logLevel >= LogLevel.ERROR) { + console.error(...args); + } } warn(...args: unknown[]): void { - console.warn(...args); + if(this.logLevel >= LogLevel.WARN) { + console.warn(...args); + } } - info(): void {} - - debug(): void {} + info(...args: unknown[]): void { + if(this.logLevel >= LogLevel.INFO) { + console.info(...args); + } + } + + debug(...args: unknown[]): void { + if(this.logLevel === LogLevel.DEBUG) { + console.debug(...args); + } + } } diff --git a/packages/shared/src/logger/logger.ts b/packages/shared/src/logger/logger.ts index 04d9c06b8..db40847a5 100644 --- a/packages/shared/src/logger/logger.ts +++ b/packages/shared/src/logger/logger.ts @@ -20,3 +20,10 @@ export interface ManageLogger { */ setLogger(logger: Logger): T; } + +export enum LogLevel { + ERROR = 1, + WARN = 2, + INFO = 3, + DEBUG = 4, +} diff --git a/packages/shared/test/logger-hook.spec.ts b/packages/shared/test/logger-hook.spec.ts new file mode 100644 index 000000000..9b2c660b6 --- /dev/null +++ b/packages/shared/test/logger-hook.spec.ts @@ -0,0 +1,158 @@ +import { GeneralError } from '../src/errors'; +import type { HookContext } from '../src/hooks/hooks'; +import { LoggingHook } from '../src/hooks/logging-hook'; +import { DefaultLogger, LOG_LEVELS, LogLevel, SafeLogger } from '../src/logger'; + +describe('LoggingHook', () => { + const FLAG_KEY = 'some-key'; + const DEFAULT_VALUE = 'default'; + const DOMAIN = 'some-domain'; + const PROVIDER_NAME = 'some-provider'; + const REASON = 'some-reason'; + const VALUE = 'some-value'; + const VARIANT = 'some-variant'; + const ERROR_MESSAGE = 'some fake error!'; + const DOMAIN_KEY = 'domain'; + const PROVIDER_NAME_KEY = 'provider_name'; + const FLAG_KEY_KEY = 'flag_key'; + const DEFAULT_VALUE_KEY = 'default_value'; + const EVALUATION_CONTEXT_KEY = 'evaluation_context'; + const ERROR_CODE_KEY = 'error_code'; + const ERROR_MESSAGE_KEY = 'error_message'; + + let hookContext: HookContext; + const logger : SafeLogger = new SafeLogger(new DefaultLogger(LogLevel.DEBUG)); + + beforeEach(() => { + const mockProviderMetaData = { name: PROVIDER_NAME }; + + // Mock the hook context + hookContext = { + flagKey: FLAG_KEY, + defaultValue: DEFAULT_VALUE, + flagValueType: 'boolean', + context: { targetingKey: 'some-targeting-key' }, + logger: logger, + clientMetadata: { domain: DOMAIN, providerMetadata: mockProviderMetaData }, + providerMetadata: mockProviderMetaData, + }; + + console.debug = jest.fn(); + console.warn = jest.fn(); + console.info = jest.fn(); + console.error = jest.fn(); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + test('should log all props except evaluation context in before hook', () => { + const hook = new LoggingHook(false); + + hook.before(hookContext); + + expect(console.debug).toHaveBeenCalled(); + + expect((console.debug as jest.Mock).mock.calls[0][0]).toMatchObject({ + stage: 'before', + [DOMAIN_KEY]: hookContext.clientMetadata.domain, + [PROVIDER_NAME_KEY]: hookContext.providerMetadata.name, + [FLAG_KEY_KEY]: hookContext.flagKey, + [DEFAULT_VALUE_KEY]: hookContext.defaultValue + }); + + }); + + test('should log all props and evaluation context in before hook when enabled', () => { + const hook = new LoggingHook(true); + + hook.before(hookContext); + + expect(console.debug).toHaveBeenCalled(); + + expect((console.debug as jest.Mock).mock.calls[0][0]).toMatchObject({ + stage: 'before', + [DOMAIN_KEY]: hookContext.clientMetadata.domain, + [PROVIDER_NAME_KEY]: hookContext.providerMetadata.name, + [FLAG_KEY_KEY]: hookContext.flagKey, + [DEFAULT_VALUE_KEY]: hookContext.defaultValue, + [EVALUATION_CONTEXT_KEY]: hookContext.context + }); + + }); + + test('should log all props except evaluation context in after hook', () => { + const hook = new LoggingHook(false); + const details = { flagKey: FLAG_KEY, flagMetadata: {}, reason: REASON, variant: VARIANT, value: VALUE }; + + hook.after(hookContext, details); + + expect(console.debug).toHaveBeenCalled(); + + expect((console.debug as jest.Mock).mock.calls[0][0]).toMatchObject({ + stage: 'after', + [DOMAIN_KEY]: hookContext.clientMetadata.domain, + [PROVIDER_NAME_KEY]: hookContext.providerMetadata.name, + [FLAG_KEY_KEY]: hookContext.flagKey, + [DEFAULT_VALUE_KEY]: hookContext.defaultValue + }); + }); + + test('should log all props and evaluation context in after hook when enabled', () => { + const hook = new LoggingHook(true); + const details = { flagKey: FLAG_KEY, flagMetadata: {}, reason: REASON, variant: VARIANT, value: VALUE }; + + hook.after(hookContext, details); + + expect(console.debug).toHaveBeenCalled(); + + expect((console.debug as jest.Mock).mock.calls[0][0]).toMatchObject({ + stage: 'after', + [DOMAIN_KEY]: hookContext.clientMetadata.domain, + [PROVIDER_NAME_KEY]: hookContext.providerMetadata.name, + [FLAG_KEY_KEY]: hookContext.flagKey, + [DEFAULT_VALUE_KEY]: hookContext.defaultValue, + [EVALUATION_CONTEXT_KEY]: hookContext.context + }); + }); + + test('should log all props except evaluation context in error hook', () => { + const hook = new LoggingHook(false); + const error = new GeneralError(ERROR_MESSAGE); + + hook.error(hookContext, error); + + expect(console.error).toHaveBeenCalled(); + + expect((console.error as jest.Mock).mock.calls[0][0]).toMatchObject({ + stage: 'error', + [ERROR_MESSAGE_KEY]: error.message, + [ERROR_CODE_KEY]: error.code, + [DOMAIN_KEY]: hookContext.clientMetadata.domain, + [PROVIDER_NAME_KEY]: hookContext.providerMetadata.name, + [FLAG_KEY_KEY]: hookContext.flagKey, + [DEFAULT_VALUE_KEY]: hookContext.defaultValue, + }); + }); + + test('should log all props and evaluation context in error hook when enabled', () => { + const hook = new LoggingHook(true); + const error = new GeneralError(ERROR_MESSAGE); + + hook.error(hookContext, error); + + expect(console.error).toHaveBeenCalled(); + + expect((console.error as jest.Mock).mock.calls[0][0]).toMatchObject({ + stage: 'error', + [ERROR_MESSAGE_KEY]: error.message, + [ERROR_CODE_KEY]: error.code, + [DOMAIN_KEY]: hookContext.clientMetadata.domain, + [PROVIDER_NAME_KEY]: hookContext.providerMetadata.name, + [FLAG_KEY_KEY]: hookContext.flagKey, + [DEFAULT_VALUE_KEY]: hookContext.defaultValue, + [EVALUATION_CONTEXT_KEY]: hookContext.context + }); + }); +});