Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: logging hook #1114

Draft
wants to merge 14 commits into
base: main
Choose a base branch
from
Draft
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 73 additions & 0 deletions packages/shared/src/hooks/logging-hook.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;

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<HookContext<FlagValue>>, evaluationDetails: EvaluationDetails<FlagValue>): 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<HookContext<FlagValue>>, 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<HookContext<FlagValue>>, evaluationDetails: EvaluationDetails<FlagValue>, 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;
}
}
}
32 changes: 26 additions & 6 deletions packages/shared/src/logger/default-logger.ts
Original file line number Diff line number Diff line change
@@ -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);
}
}
}
7 changes: 7 additions & 0 deletions packages/shared/src/logger/logger.ts
Original file line number Diff line number Diff line change
@@ -20,3 +20,10 @@ export interface ManageLogger<T> {
*/
setLogger(logger: Logger): T;
}

export enum LogLevel {
ERROR = 1,
WARN = 2,
INFO = 3,
DEBUG = 4,
}
158 changes: 158 additions & 0 deletions packages/shared/test/logger-hook.spec.ts
Original file line number Diff line number Diff line change
@@ -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
});
});
});