From 65109945bf3094a8d7c70bee6491daae280c651a Mon Sep 17 00:00:00 2001 From: mpyw Date: Tue, 6 Jul 2021 03:12:55 +0900 Subject: [PATCH] [WIP] Refectoring Tests are failing currently --- package.json | 19 +- src/cache.ts | 22 ++ src/channel/channel.ts | 42 +++ src/channel/channelCollection.ts | 45 ++++ src/channel/channelFactory.ts | 51 ++++ src/channel/index.ts | 3 + src/collection.ts | 39 +++ src/collector/collector.ts | 15 ++ src/collector/collectorCollection.ts | 53 ++++ src/collector/collectorFactory.ts | 12 + src/collector/index.ts | 3 + src/environment/environment.ts | 48 ++++ src/environment/index.ts | 2 + src/environment/installedEnvironment.ts | 11 + src/factory.ts | 71 +++++ src/handler.ts | 50 ++++ src/index.ts | 22 +- src/installer.ts | 79 ++++++ src/logger.ts | 345 ++---------------------- src/message.ts | 67 +++++ src/sender.ts | 108 ++++++++ src/source/eventSink.ts | 36 +++ src/source/index.ts | 4 + src/source/source.ts | 59 ++++ src/source/sourceCollection.ts | 73 +++++ src/source/sourceFactory.ts | 52 ++++ src/types.ts | 158 ++++++++--- src/util/array.ts | 3 + src/util/error.ts | 53 ++++ src/util/index.ts | 2 + src/worker/index.ts | 2 + src/worker/worker.ts | 46 ++++ src/worker/workerCollection.ts | 49 ++++ tests/cache.test.ts | 33 +++ tests/channel/channel.test.ts | 60 +++++ tests/channel/channelCollection.test.ts | 61 +++++ tests/channel/channelFactory.test.ts | 52 ++++ tests/handler.test.ts | 49 ++++ tests/integration.test.ts | 330 +++++++++++++++++++++++ tests/main.test.ts | 319 ---------------------- tests/sender.test.ts | 110 ++++++++ tests/source/eventSink.test.ts | 82 ++++++ tests/source/source.test.ts | 74 +++++ tests/source/sourceCollection.test.ts | 36 +++ tests/source/sourceFactory.test.ts | 70 +++++ tests/stub.ts | 34 ++- tests/util/error.test.ts | 55 ++++ 47 files changed, 2302 insertions(+), 707 deletions(-) create mode 100644 src/cache.ts create mode 100644 src/channel/channel.ts create mode 100644 src/channel/channelCollection.ts create mode 100644 src/channel/channelFactory.ts create mode 100644 src/channel/index.ts create mode 100644 src/collection.ts create mode 100644 src/collector/collector.ts create mode 100644 src/collector/collectorCollection.ts create mode 100644 src/collector/collectorFactory.ts create mode 100644 src/collector/index.ts create mode 100644 src/environment/environment.ts create mode 100644 src/environment/index.ts create mode 100644 src/environment/installedEnvironment.ts create mode 100644 src/factory.ts create mode 100644 src/handler.ts create mode 100644 src/installer.ts create mode 100644 src/message.ts create mode 100644 src/sender.ts create mode 100644 src/source/eventSink.ts create mode 100644 src/source/index.ts create mode 100644 src/source/source.ts create mode 100644 src/source/sourceCollection.ts create mode 100644 src/source/sourceFactory.ts create mode 100644 src/util/array.ts create mode 100644 src/util/error.ts create mode 100644 src/util/index.ts create mode 100644 src/worker/index.ts create mode 100644 src/worker/worker.ts create mode 100644 src/worker/workerCollection.ts create mode 100644 tests/cache.test.ts create mode 100644 tests/channel/channel.test.ts create mode 100644 tests/channel/channelCollection.test.ts create mode 100644 tests/channel/channelFactory.test.ts create mode 100644 tests/handler.test.ts create mode 100644 tests/integration.test.ts delete mode 100644 tests/main.test.ts create mode 100644 tests/sender.test.ts create mode 100644 tests/source/eventSink.test.ts create mode 100644 tests/source/source.test.ts create mode 100644 tests/source/sourceCollection.test.ts create mode 100644 tests/source/sourceFactory.test.ts create mode 100644 tests/util/error.test.ts diff --git a/package.json b/package.json index 5c2def2..dbfd78d 100644 --- a/package.json +++ b/package.json @@ -17,18 +17,19 @@ "tslib": "^2.3.0" }, "devDependencies": { - "@types/jest": "^26.0.23", - "@types/node": "^15.14.0", - "@typescript-eslint/eslint-plugin": "^4.28.1", - "@typescript-eslint/parser": "^4.28.1", - "eslint": "^7.29.0", + "@types/jest": "^27.0.2", + "@types/node": "^16.10.9", + "@typescript-eslint/eslint-plugin": "^5.0.0", + "@typescript-eslint/parser": "^5.0.0", + "eslint": "^8.0.1", "eslint-config-prettier": "^8.3.0", - "jest": "^27.0.6", + "event-target-shim": "^6.0.2", + "jest": "^27.2.5", "jest-date-mock": "^1.0.8", "npm-run-all": "^4.1.5", - "prettier": "^2.3.2", - "ts-jest": "^27.0.3", - "typescript": "^4.3.5" + "prettier": "^2.4.1", + "ts-jest": "^27.0.5", + "typescript": "^4.4.4" }, "scripts": { "check": "tsc --noEmit", diff --git a/src/cache.ts b/src/cache.ts new file mode 100644 index 0000000..a68a1c0 --- /dev/null +++ b/src/cache.ts @@ -0,0 +1,22 @@ +import { StorageInterface } from './types'; + +export class Cache { + constructor( + protected readonly storage: StorageInterface | Cache, + protected readonly namespace: string + ) {} + + async setItem(key: string, value: string): Promise { + return this.storage.setItem(`${this.namespace}:${key}`, value); + } + + async getItem(key: string): Promise { + return this.storage.getItem(`${this.namespace}:${key}`); + } + + async removeItem(...keys: readonly string[]): Promise { + await Promise.all( + keys.map((key) => this.storage.removeItem(`${this.namespace}:${key}`)) + ); + } +} diff --git a/src/channel/channel.ts b/src/channel/channel.ts new file mode 100644 index 0000000..af572ff --- /dev/null +++ b/src/channel/channel.ts @@ -0,0 +1,42 @@ +import { + ChannelOptions, + ClientInterface, + CreateLogStreamPayload, + LogStreamNameResolver, + NonEmptyString, + PutLogEventsPayload, +} from '../types'; +import { InputLogEvent } from '@aws-sdk/client-cloudwatch-logs'; + +export class Channel { + readonly client: ClientInterface; + readonly logGroupName: string; + readonly logStreamNameResolver: LogStreamNameResolver; + readonly interval: number; + + constructor(readonly name: NonEmptyString, options: ChannelOptions) { + this.client = options.client; + this.logGroupName = options.logGroupName; + this.logStreamNameResolver = options.logStreamNameResolver; + this.interval = options.interval; + } + + async createPutLogEventsPayload( + events: readonly InputLogEvent[], + sequenceToken?: string | null + ): Promise { + return { + logGroupName: this.logGroupName, + logStreamName: await this.logStreamNameResolver(), + logEvents: events, + ...(sequenceToken ? { sequenceToken } : undefined), + }; + } + + async createCreateLogStreamPayload(): Promise { + return { + logGroupName: this.logGroupName, + logStreamName: await this.logStreamNameResolver(), + }; + } +} diff --git a/src/channel/channelCollection.ts b/src/channel/channelCollection.ts new file mode 100644 index 0000000..dc440c7 --- /dev/null +++ b/src/channel/channelCollection.ts @@ -0,0 +1,45 @@ +import { Channel } from './channel'; +import { wrapAsArray } from '../util'; +import { Collection } from '../collection'; + +export class ChannelCollection extends Collection { + static wrap( + channels: + | Channel + | readonly Channel[] + | Collection + | ChannelCollection + ): ChannelCollection { + return channels instanceof ChannelCollection + ? channels + : new ChannelCollection( + channels instanceof Collection + ? channels.items + : wrapAsArray(channels) + ); + } + + constructor(readonly items: readonly Channel[]) { + super('Channel', items); + } + + filterByNamePrefix( + namePrefix: string | readonly string[] + ): ChannelCollection { + return ChannelCollection.wrap(this.filterByPrefix('name', namePrefix)); + } + + filterByLogGroupName( + logGroupName: string | readonly string[] + ): ChannelCollection { + return ChannelCollection.wrap(this.filterBy('logGroupName', logGroupName)); + } + + findByName(name: string): Channel | undefined { + return this.findBy('name', name); + } + + findByLogGroupName(logGroupName: string): Channel | undefined { + return this.findBy('logGroupName', logGroupName); + } +} diff --git a/src/channel/channelFactory.ts b/src/channel/channelFactory.ts new file mode 100644 index 0000000..4960944 --- /dev/null +++ b/src/channel/channelFactory.ts @@ -0,0 +1,51 @@ +import { + ChannelFactoryOptions, + ClientInterface, + Exact, + LogStreamNameResolver, + NonEmptyString, + RequireMissing, +} from '../types'; +import { Channel } from './channel'; + +export class ChannelFactory> { + protected readonly client: ClientInterface | null; + protected readonly logGroupName: string | null; + protected readonly logStreamNameResolver: LogStreamNameResolver; + protected readonly interval: number; + + // Any essential parameters can be omitted in constructor. + constructor(options?: Exact>) { + this.client = options?.client ?? null; + this.logGroupName = options?.logGroupName ?? null; + this.logStreamNameResolver = + options?.logStreamNameResolver ?? (() => 'anonymous'); + this.interval = options?.interval ?? 3000; + } + + // Omitted parameters in constructor must be passed here. + createChannel( + name: NonEmptyString, + ...args: RequireMissing + ): Channel; + createChannel( + name: string, + options?: Partial + ): Channel { + const client = options?.client ?? this.client; + if (!client) { + throw new Error('Missing channel config: client'); + } + const logGroupName = options?.logGroupName ?? this.logGroupName; + if (!logGroupName) { + throw new Error('Missing channel config: logGroupName'); + } + return new Channel(name, { + client, + logGroupName, + logStreamNameResolver: + options?.logStreamNameResolver ?? this.logStreamNameResolver, + interval: options?.interval ?? this.interval, + }); + } +} diff --git a/src/channel/index.ts b/src/channel/index.ts new file mode 100644 index 0000000..54a8266 --- /dev/null +++ b/src/channel/index.ts @@ -0,0 +1,3 @@ +export * from './channel'; +export * from './channelCollection'; +export * from './channelFactory'; diff --git a/src/collection.ts b/src/collection.ts new file mode 100644 index 0000000..bd05bbc --- /dev/null +++ b/src/collection.ts @@ -0,0 +1,39 @@ +import { wrapAsArray } from './util'; +import { KeyOfType } from './types'; + +export class Collection { + constructor(readonly type: string, readonly items: readonly T[]) {} + + protected filterBy( + by: B, + values: T[B] | readonly T[B][] + ): Collection { + const search = wrapAsArray(values); + return new Collection( + this.type, + this.items.filter((item) => search.includes(item[by])) + ); + } + + protected filterByPrefix>( + by: B, + values: string | readonly string[] + ): Collection { + const search = wrapAsArray(values); + return new Collection( + this.type, + this.items.filter((item) => + search.some((prefix) => { + const value = item[by]; + return ( + typeof value === 'string' && (value as string).startsWith(prefix) + ); + }) + ) + ); + } + + protected findBy(by: B, value: T[B]): T | undefined { + return this.filterBy(by, value).items[0]; + } +} diff --git a/src/collector/collector.ts b/src/collector/collector.ts new file mode 100644 index 0000000..46c7930 --- /dev/null +++ b/src/collector/collector.ts @@ -0,0 +1,15 @@ +import { SourceCollection } from '../source'; +import { Channel } from '../channel'; +import { Sender } from '../sender'; + +export class Collector { + constructor(readonly channel: Channel, readonly sources: SourceCollection) {} + + get channelName(): string { + return this.channel.name; + } + + async collect(sender: Sender): Promise { + return sender.send(this.sources.flush()); + } +} diff --git a/src/collector/collectorCollection.ts b/src/collector/collectorCollection.ts new file mode 100644 index 0000000..a1a3cbc --- /dev/null +++ b/src/collector/collectorCollection.ts @@ -0,0 +1,53 @@ +import { Collector } from './collector'; +import { wrapAsArray } from '../util'; +import { Collection } from '../collection'; +import { Source, SourceCollection } from '../source'; +import { ChannelCollection } from '../channel'; + +export class CollectorCollection extends Collection { + static wrap( + collectors: + | Collector + | readonly Collector[] + | Collection + | CollectorCollection + ): CollectorCollection { + return collectors instanceof CollectorCollection + ? collectors + : new CollectorCollection( + collectors instanceof Collection + ? collectors.items + : wrapAsArray(collectors) + ); + } + + constructor(readonly items: readonly Collector[]) { + super('Collector', items); + } + + get channels(): ChannelCollection { + return ChannelCollection.wrap( + this.items.map((collector) => collector.channel) + ); + } + + get sources(): SourceCollection { + return SourceCollection.wrap( + ([] as readonly Source[]).concat( + ...this.items.map((collector) => collector.sources.items) + ) + ); + } + + filterByChannelNamePrefix( + namePrefix: string | readonly string[] + ): CollectorCollection { + return CollectorCollection.wrap( + this.filterByPrefix('channelName', namePrefix) + ); + } + + findByChannelName(name: string): Collector | undefined { + return this.findBy('channelName', name); + } +} diff --git a/src/collector/collectorFactory.ts b/src/collector/collectorFactory.ts new file mode 100644 index 0000000..7ff6cfe --- /dev/null +++ b/src/collector/collectorFactory.ts @@ -0,0 +1,12 @@ +import { Source, SourceCollection } from '../source'; +import { Channel } from '../channel'; +import { Collector } from './collector'; + +export class CollectorFactory { + createCollector( + channel: Channel, + sources: Source | readonly Source[] | SourceCollection + ): Collector { + return new Collector(channel, SourceCollection.wrap(sources)); + } +} diff --git a/src/collector/index.ts b/src/collector/index.ts new file mode 100644 index 0000000..56b2074 --- /dev/null +++ b/src/collector/index.ts @@ -0,0 +1,3 @@ +export * from './collector'; +export * from './collectorCollection'; +export * from './collectorFactory'; diff --git a/src/environment/environment.ts b/src/environment/environment.ts new file mode 100644 index 0000000..4331bf7 --- /dev/null +++ b/src/environment/environment.ts @@ -0,0 +1,48 @@ +import { + ConsoleInterface, + EnvironmentalOptions, + StorageInterface, +} from '../types'; +import { BrowserPolyfillRequiredError } from '../util'; + +export class Environment implements EnvironmentalOptions { + readonly storage: StorageInterface; + readonly console: ConsoleInterface; + readonly setInterval: typeof setInterval; + readonly clearInterval: typeof clearInterval; + readonly setTimeout: typeof setTimeout; + readonly eventTarget: EventTarget; + + constructor(options?: EnvironmentalOptions) { + this.storage = getStorageImpl(options); + this.console = options?.console ?? console; + this.setInterval = options?.setInterval ?? setInterval; + this.clearInterval = options?.clearInterval ?? clearInterval; + this.setTimeout = options?.setTimeout ?? setTimeout; + this.eventTarget = getEventTargetImpl(options); + } +} + +const getStorageImpl = (options?: EnvironmentalOptions): StorageInterface => { + return ( + options?.storage ?? + (typeof localStorage !== 'undefined' + ? localStorage + : needsPolyfill(['localStorage'])) + ); +}; + +const getEventTargetImpl = (options?: EnvironmentalOptions): EventTarget => { + if (options?.eventTarget) { + return options?.eventTarget; + } + const top = typeof window !== 'undefined' ? window : globalThis; + if (!top.addEventListener || !top.dispatchEvent) { + needsPolyfill(['addEventListener', 'dispatchEvent']); + } + return top; +}; + +const needsPolyfill = (components: readonly (keyof Window)[]): never => { + throw new BrowserPolyfillRequiredError(components); +}; diff --git a/src/environment/index.ts b/src/environment/index.ts new file mode 100644 index 0000000..32ec681 --- /dev/null +++ b/src/environment/index.ts @@ -0,0 +1,2 @@ +export * from './environment'; +export * from './installedEnvironment'; diff --git a/src/environment/installedEnvironment.ts b/src/environment/installedEnvironment.ts new file mode 100644 index 0000000..5927566 --- /dev/null +++ b/src/environment/installedEnvironment.ts @@ -0,0 +1,11 @@ +import { ConsoleInterface } from '../types'; +import { Environment } from './environment'; + +export class InstalledEnvironment extends Environment { + constructor( + readonly originalConsole: ConsoleInterface, + environment: Environment + ) { + super(environment); + } +} diff --git a/src/factory.ts b/src/factory.ts new file mode 100644 index 0000000..2329116 --- /dev/null +++ b/src/factory.ts @@ -0,0 +1,71 @@ +import { Channel, ChannelFactory } from './channel'; +import { Source, SourceCollection, SourceFactory } from './source'; +import { Collector, CollectorCollection, CollectorFactory } from './collector'; +import { Installer } from './installer'; +import { + ChannelFactoryOptions, + ChannelOptions, + CreateSourceOptionsWithoutLevel, + EnvironmentalOptions, + Exact, + FactoryHelper, + Level, + SourceFactoryOptions, +} from './types'; +import { Logger } from './logger'; +import { Environment } from './environment'; +import { BrowserPolyfillRequiredError } from './util'; + +export const createFactory = >({ + channelFactoryOptions, + sourceFactoryOptions, + environment, +}: { + channelFactoryOptions?: Exact>; + sourceFactoryOptions?: SourceFactoryOptions; + environment?: Environment | EnvironmentalOptions; +} = {}): FactoryHelper => { + const instance = { + channel: new ChannelFactory(channelFactoryOptions), + source: new SourceFactory(sourceFactoryOptions), + collector: new CollectorFactory(), + installer: new Installer(environment), + }; + return { + install( + collectors: Collector | readonly Collector[] | CollectorCollection + ): Logger { + return instance.installer.install(collectors); + }, + createChannel(name: string, options: ChannelOptions) { + return instance.channel.createChannel(name, options); + }, + createSource(...args: unknown[]): Source { + return instance.source.createSource( + ...(args as Parameters['createSource']>) + ); + }, + createSources( + levels: readonly Level[], + options?: CreateSourceOptionsWithoutLevel + ): SourceCollection { + return instance.source.createSources(levels, options); + }, + createCollector( + channel: Channel, + sources: Source | readonly Source[] | SourceCollection + ): Collector { + return instance.collector.createCollector(channel, sources); + }, + }; +}; + +let defaultFactoryForBrowser = null as unknown as FactoryHelper; +try { + defaultFactoryForBrowser = createFactory(); +} catch (e: unknown) { + if (!(e instanceof BrowserPolyfillRequiredError)) { + throw e; + } +} +export const factory = defaultFactoryForBrowser; diff --git a/src/handler.ts b/src/handler.ts new file mode 100644 index 0000000..95c482e --- /dev/null +++ b/src/handler.ts @@ -0,0 +1,50 @@ +import { ConsoleInterface, ConsoleListener, Level } from './types'; +import { SourceCollection } from './source'; +import { ConsoleMessage, CustomMessage, ErrorEventMessage } from './message'; + +export class Handler { + constructor( + protected readonly originalConsole: ConsoleInterface, + protected readonly sources: SourceCollection + ) {} + + readonly debug: ConsoleListener = (message, ...args) => + this.consoleCall('debug', message, ...args); + readonly info: ConsoleListener = (message, ...args) => + this.consoleCall('info', message, ...args); + readonly log: ConsoleListener = (message, ...args) => + this.consoleCall('log', message, ...args); + readonly warn: ConsoleListener = (message, ...args) => + this.consoleCall('warn', message, ...args); + readonly error: ConsoleListener = (message, ...args) => + this.consoleCall('error', message, ...args); + + readonly uncaught = async (event: ErrorEvent): Promise => { + return this.sources + .filterByLevel('error') + .push(new ErrorEventMessage(event)); + }; + + readonly notify = async ( + level: Level, + error: unknown, + ...params: readonly unknown[] + ): Promise => { + const sources = this.sources.filterByLevel(level); + await sources.push(new CustomMessage(error, params)); + }; + + protected async consoleCall( + level: Level, + message: unknown, + ...params: readonly unknown[] + ): Promise { + const sources = this.sources.filterByLevel(level); + + if (!sources.muted) { + this.originalConsole[level](message, ...params); + } + + await sources.push(new ConsoleMessage(level, message, params)); + } +} diff --git a/src/index.ts b/src/index.ts index 6082e01..36ffff0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,18 @@ -import Logger from './logger'; - -export { Logger }; -export default Logger; - +export * from './channel'; +export * from './collector'; +export * from './environment'; +export * from './source'; +export * from './util'; +export * from './worker'; +export * from './cache'; +export * from './collection'; +export * from './factory'; +export * from './handler'; +export * from './installer'; +export * from './logger'; +export * from './message'; +export * from './sender'; export * from './types'; + +import { factory } from './factory'; +export default factory; diff --git a/src/installer.ts b/src/installer.ts new file mode 100644 index 0000000..f2c4c41 --- /dev/null +++ b/src/installer.ts @@ -0,0 +1,79 @@ +import { ConsoleInterface, EnvironmentalOptions, Level } from './types'; +import { Environment, InstalledEnvironment } from './environment'; +import { Handler } from './handler'; +import { Logger } from './logger'; +import { Collector, CollectorCollection } from './collector'; +import { Worker, WorkerCollection } from './worker'; + +export class Installer { + protected environment: Environment; + + constructor(environment?: Environment | EnvironmentalOptions) { + this.environment = + environment instanceof Environment + ? environment + : new Environment(environment); + } + + install( + collectors: Collector | readonly Collector[] | CollectorCollection + ): Logger { + if (this.environment instanceof InstalledEnvironment) { + throw new Error('Already installed!'); + } + + const collectorCollection = CollectorCollection.wrap(collectors); + const originalConsole = {} as ConsoleInterface; + const handler = new Handler(originalConsole, collectorCollection.sources); + + const installedEnvironment = (this.environment = this.injectHandler( + handler, + originalConsole + )); + this.listenUncaughtError(handler); + const workers = this.setUpWorkers( + installedEnvironment, + collectorCollection + ); + + const logger = new Logger(installedEnvironment, handler, workers); + logger.workers.start(); + + return logger; + } + + protected injectHandler( + handler: Handler, + originalConsole: ConsoleInterface + ): InstalledEnvironment { + const levels: readonly Level[] = ['debug', 'info', 'log', 'warn', 'error']; + + for (const level of levels) { + // Swap window.console.*() functions and overridden ones (type="console") + originalConsole[level] = this.environment.console[level].bind( + this.environment.console + ); + this.environment.console[level] = handler[level]; + } + + return new InstalledEnvironment(originalConsole, this.environment); + } + + protected listenUncaughtError(handler: Handler): void { + // Listen "error" event on window (type="uncaught") + this.environment.eventTarget.addEventListener( + 'error', + handler.uncaught as (evt: Event) => Promise + ); + } + + protected setUpWorkers( + environment: InstalledEnvironment, + collectors: CollectorCollection + ): WorkerCollection { + // Start timer that executes this.onInterval() + return WorkerCollection.wrap( + collectors.items.map((collector) => new Worker(environment, collector)) + ); + } +} diff --git a/src/logger.ts b/src/logger.ts index 871c6d6..a5444be 100644 --- a/src/logger.ts +++ b/src/logger.ts @@ -1,335 +1,32 @@ -import { - CloudWatchLogsClient, - CreateLogStreamCommand, - InputLogEvent, - PutLogEventsCommand, -} from '@aws-sdk/client-cloudwatch-logs'; -import { - Level, - StorageInterface, - ConsoleInterface, - MessageFormatter, - LogStreamNameResolver, - InstallOptions, - ErrorInfo, - AWSError, - ClientInterface, -} from './types'; - -export default class Logger { - protected static readonly namespace: string = 'CloudWatchFrontLogger'; - protected static readonly defaultLogStreamName: string = 'anonymous'; - - protected levels: Level[] = ['error']; - protected interval = 10000; - protected muting = false; - protected enabled = true; - - protected logStreamNameResolver?: LogStreamNameResolver; - protected messageFormatter?: MessageFormatter; - protected client?: ClientInterface; - protected storage?: StorageInterface; - protected console?: ConsoleInterface; - - protected events: InputLogEvent[] = []; - protected intervalId?: NodeJS.Timeout | number; - - /** - * Constructor. - * - * @param accessKeyId - AWS Access Key ID - * @param secretAccessKey - AWS Secret Access Key - * @param region - AWS Region (e.g. ap-northeast-1) - * @param logGroupName - AWS CloudWatch Log Group Name - */ +import { Handler } from './handler'; +import { InstalledEnvironment } from './environment'; +import { WorkerCollection } from './worker'; +import { CollectorCollection } from './collector'; +import { ChannelCollection } from './channel'; +import { SourceCollection } from './source'; + +export class Logger { constructor( - protected readonly accessKeyId: string, - protected readonly secretAccessKey: string, - protected readonly region: string, - protected readonly logGroupName: string + protected readonly environment: InstalledEnvironment, + protected readonly handler: Handler, + readonly workers: WorkerCollection ) {} - /** - * Set level. - * - * @param levels - Reported error level - */ - public setLevels(levels: Level[]): this { - this.levels = levels; - return this; - } - - /** - * Set interval. - * - * @param interval - Interval milliseconds for sending logs - */ - public setInterval(interval: number): this { - this.interval = interval; - return this; - } - - /** - * Mute logging in browser console. - */ - public mute(): this { - this.muting = true; - return this; - } - - /** - * Resume logging in browser console. - */ - public unmute(): this { - this.muting = false; - return this; - } - - /** - * Enable collecting errors and sending to AWS CloudWatch. - */ - public enable(): this { - this.enabled = true; - return this; - } - - /** - * Disable collecting errors and sending to AWS CloudWatch. - */ - public disable(): this { - this.enabled = false; - return this; - } - - /** - * Bootstrap Logger. - * - * @param logStreamNameResolver - Resolve logStreamName for current user (e.g. Canvas Fingerprint) - * @param messageFormatter - Format message string from Error - * @param Ctor - * @param storage - * @param globalConsole - * @param eventTarget - */ - public install({ - logStreamNameResolver, - messageFormatter, - ClientConstructor: Ctor = CloudWatchLogsClient, - storage = localStorage, - console: globalConsole = console, - eventTarget = window, - }: InstallOptions = {}): void { - this.client = new Ctor({ - credentials: { - accessKeyId: this.accessKeyId, - secretAccessKey: this.secretAccessKey, - }, - region: this.region, - }); - this.logStreamNameResolver = logStreamNameResolver; - this.messageFormatter = messageFormatter; - this.storage = storage; - - // Swap window.console.*() functions and overridden ones - const originalConsole = {} as ConsoleInterface; - for (const level of this.levels) { - originalConsole[level] = globalConsole[level].bind(globalConsole); - globalConsole[level] = async (message, ...args): Promise => { - // Listen overridden console.*() function calls (type="console", level="*") - await this.onError(new Error(message), { type: 'console', level }); - if (!this.muting) { - originalConsole[level](message, ...args); - } - }; - } - this.console = originalConsole; - - // Listen "error" event on window (type="uncaught") - eventTarget.addEventListener( - 'error', - async (error: unknown): Promise => { - await this.onError(error, { type: 'uncaught' }); - } - ); - - // Start timer that executes this.onInterval() - this.intervalId = setInterval(this.onInterval.bind(this), this.interval); - } - - /** - * Queue a new error. - * - * @param e - Error object - * @param info - Extra Error Info (Consider using "type" field) - */ - public async onError(e: unknown, info?: ErrorInfo): Promise { - if (!Logger.isValidError(e) || !this.enabled) { - return; - } - - const message = this.messageFormatter - ? await this.messageFormatter(e, info) // Custom formatter - : JSON.stringify({ message: e.message, ...info }); // Simple JSON formatter - - // Abort when received null - if (!message) { - return; - } - - this.events.push({ - timestamp: new Date().getTime(), - message, - }); + get collectors(): CollectorCollection { + return this.workers.collectors; } - /** - * Send queued errors. - */ - public async onInterval(): Promise { - if (!this.enabled) { - return; - } - - // Extract errors from queue - const pendingEvents = this.events.splice(0); - if (!pendingEvents.length) { - return; - } - - // Retrieve or newly calculate logStreamName for current user - const logStreamName = await this.getLogStreamName(); - if (!logStreamName) { - return; - } - - // Retrieve previous "nextSequenceToken" from cache - const sequenceToken = await this.getCache('sequenceToken'); - - // Build parameters for PutLogEvents endpoint - // c.f. https://docs.aws.amazon.com/AmazonCloudWatchLogs/latest/APIReference/API_PutLogEvents.html - const command = new PutLogEventsCommand({ - logEvents: pendingEvents, - logGroupName: this.logGroupName, - logStreamName: logStreamName, - ...(sequenceToken ? { sequenceToken } : undefined), - }); - - let nextSequenceToken: string | undefined = undefined; - let needsRetry = false; - - try { - // Run request to send events and retrieve fresh "nextSequenceToken" - ({ nextSequenceToken = undefined } = await this.getClient().send( - command - )); - } catch (e) { - // Try to recover from InvalidSequenceTokenException error message - if ( - !Logger.isValidError(e) || - (e.name !== 'DataAlreadyAcceptedException' && - e.name !== 'InvalidSequenceTokenException') || - !e.expectedSequenceToken - ) { - // Print error to original console and reset states - this.getConsole().error(e); - await this.refresh(); - return; - } - // Recover from InvalidSequenceTokenException error message - nextSequenceToken = e.expectedSequenceToken; - needsRetry = e.name !== 'DataAlreadyAcceptedException'; - } - - // Cache fresh "nextSequenceToken" - if (nextSequenceToken) { - await this.setCache('sequenceToken', nextSequenceToken); - } - - // Immediately retry after recovery - if (needsRetry) { - this.events.push(...pendingEvents); - setTimeout(this.onInterval, 0); - } - } - - protected getClient(): ClientInterface { - if (!this.client) { - throw new Error('Not yet installed'); - } - return this.client; + get channels(): ChannelCollection { + return this.workers.channels; } - protected getStorage(): StorageInterface { - if (!this.storage) { - throw new Error('Not yet installed'); - } - return this.storage; - } - - protected getConsole(): ConsoleInterface { - if (!this.console) { - throw new Error('Not yet installed'); - } - return this.console; - } - - protected async setCache(key: string, value: string): Promise { - return this.getStorage().setItem(`${Logger.namespace}:${key}`, value); - } - - protected async getCache(key: string): Promise { - return this.getStorage().getItem(`${Logger.namespace}:${key}`); - } - - protected async deleteCache(key: string): Promise { - return this.getStorage().removeItem(`${Logger.namespace}:${key}`); - } - - protected async refresh(): Promise { - await this.deleteCache('logStreamName'); - await this.deleteCache('sequenceToken'); - this.events.splice(0); - } - - protected async getLogStreamName(): Promise { - // Retrieve "logStreamName" for current user - const retrieved = await this.getCache('logStreamName'); - if (retrieved) { - return retrieved; - } - - // Build parameters for CreateLogStream endpoint - // c.f. https://docs.aws.amazon.com/AmazonCloudWatchLogs/latest/APIReference/API_CreateLogStream.html - const logStreamName = - (this.logStreamNameResolver && (await this.logStreamNameResolver())) || // Resolve for current user (e.g. Canvas Fingerprint) - Logger.defaultLogStreamName; // "anonymous" - const createLogStreamCommand = new CreateLogStreamCommand({ - logGroupName: this.logGroupName, - logStreamName, - }); - - try { - // Run request to create a new logStream - await this.getClient().send(createLogStreamCommand); - } catch (e) { - // Try to recover from ResourceAlreadyExistsException error - if ( - !Logger.isValidError(e) || - e.name !== 'ResourceAlreadyExistsException' - ) { - // Print error to original console and reset states - this.getConsole().error(e); - await this.refresh(); - return null; - } - } - - // Cache fresh "logStreamName" - await this.setCache('logStreamName', logStreamName); - return logStreamName; + get sources(): SourceCollection { + return this.workers.sources; } - protected static isValidError(value: unknown): value is E { - return Boolean(value && typeof (value as Error).message === 'string'); + notify( + ...args: Parameters + ): ReturnType { + return this.handler.notify(...args); } } diff --git a/src/message.ts b/src/message.ts new file mode 100644 index 0000000..010a4a8 --- /dev/null +++ b/src/message.ts @@ -0,0 +1,67 @@ +import { JsonMessage, Level, Message } from './types'; + +export class ConsoleMessage implements Message<'console'> { + readonly type = 'console' as const; + constructor( + readonly level: Level, + readonly error: unknown, + readonly params: readonly unknown[] + ) {} + + toJSON(): JsonMessage< + 'console', + { + readonly level: Level; + readonly params: readonly unknown[]; + } + > { + return { + type: this.type, + message: formatError(this.error), + level: this.level, + params: this.params, + }; + } +} + +export class ErrorEventMessage implements Message<'uncaught', Error> { + readonly type = 'uncaught' as const; + readonly error: Error; + + constructor(readonly event: ErrorEvent) { + this.error = event.error; + } + + toJSON(): JsonMessage<'uncaught'> { + return { + type: this.type, + message: formatError(this.error), + }; + } +} + +export class CustomMessage + implements Message<'custom'> +{ + readonly type = 'custom' as const; + constructor(readonly error: E, readonly params: P) {} + + toJSON(): JsonMessage<'custom', { readonly params: P }> { + return { + type: this.type, + message: formatError(this.error), + params: this.params, + }; + } +} + +const formatError = (error: unknown): string => { + if (error instanceof Error) { + return `${error}`; + } + const castedError = error as unknown as Error; + if (typeof castedError.message === 'string') { + return castedError.message; + } + return `${error}`; +}; diff --git a/src/sender.ts b/src/sender.ts new file mode 100644 index 0000000..3c27700 --- /dev/null +++ b/src/sender.ts @@ -0,0 +1,108 @@ +import { Channel } from './channel'; +import { Cache } from './cache'; +import { + isStreamAlreadyExistsError, + isUnrecoverableSequenceTokenError, + isValidAWSError, +} from './util'; +import { + CreateLogStreamCommand, + InputLogEvent, + PutLogEventsCommand, +} from '@aws-sdk/client-cloudwatch-logs'; +import { InstalledEnvironment } from './environment'; + +export class Sender { + constructor( + protected readonly environment: InstalledEnvironment, + protected readonly channel: Channel, + protected readonly cache: Cache + ) {} + + async send(events: readonly InputLogEvent[]): Promise { + if (!events.length) { + return; + } + + // Retrieve previous "nextSequenceToken" from cache + const sequenceToken = await this.cache.getItem('sequenceToken'); + + // Build parameters for PutLogEvents endpoint + // c.f. https://docs.aws.amazon.com/AmazonCloudWatchLogs/latest/APIReference/API_PutLogEvents.html + const payload = await this.channel.createPutLogEventsPayload( + events, + sequenceToken + ); + const command = new PutLogEventsCommand({ + ...payload, + logEvents: [...payload.logEvents], + }); + + let nextSequenceToken: string | undefined = undefined; + let needsRetry = false; + + try { + // Run request to send events and retrieve fresh "nextSequenceToken" + ({ nextSequenceToken = undefined } = await this.channel.client.send( + command + )); + } catch (e: unknown) { + // Try to recover from InvalidSequenceTokenException error message + if (!isValidAWSError(e) || isUnrecoverableSequenceTokenError(e)) { + // Print error to original console and reset states + this.environment.originalConsole.error(e); + await this.refresh(); + return; + } + // Recover from InvalidSequenceTokenException error message + nextSequenceToken = e.expectedSequenceToken; + needsRetry = e.name !== 'DataAlreadyAcceptedException'; + } + + // Cache fresh "nextSequenceToken" + if (nextSequenceToken) { + await this.cache.setItem('sequenceToken', nextSequenceToken); + } + + // Immediately retry after recovery + if (needsRetry) { + this.environment.setTimeout(() => this.send(events)); + } + } + + protected async refresh(): Promise { + await this.cache.removeItem('logStreamName', 'sequenceToken'); + } + + protected async getLogStreamName(): Promise { + // Retrieve "logStreamName" for current user + const retrieved = await this.cache.getItem('logStreamName'); + if (retrieved) { + return retrieved; + } + + // Build parameters for CreateLogStream endpoint + // c.f. https://docs.aws.amazon.com/AmazonCloudWatchLogs/latest/APIReference/API_CreateLogStream.html + const logStreamName = await this.channel.logStreamNameResolver(); + const createLogStreamCommand = new CreateLogStreamCommand( + await this.channel.createCreateLogStreamPayload() + ); + + try { + // Run request to create a new logStream + await this.channel.client.send(createLogStreamCommand); + } catch (e: unknown) { + // Try to recover from ResourceAlreadyExistsException error + if (!isValidAWSError(e) || !isStreamAlreadyExistsError(e)) { + // Print error to original console and reset states + this.environment.originalConsole.error(e); + await this.refresh(); + return null; + } + } + + // Cache fresh "logStreamName" + await this.cache.setItem('logStreamName', logStreamName); + return logStreamName; + } +} diff --git a/src/source/eventSink.ts b/src/source/eventSink.ts new file mode 100644 index 0000000..4200e89 --- /dev/null +++ b/src/source/eventSink.ts @@ -0,0 +1,36 @@ +import { InputLogEvent } from '@aws-sdk/client-cloudwatch-logs'; +import { Source } from './index'; +import { Message } from '../types'; + +export class EventSink { + readonly events: InputLogEvent[]; + + constructor( + protected readonly source: Source, + protected readonly timestampProvider: () => number + ) { + this.events = []; + } + + async push(...messages: readonly Message[]): Promise { + if (this.source.disabled) { + return; + } + this.events.push( + ...( + await Promise.all( + messages.map((message) => this.source.messageFormatter(message)) + ) + ) + .filter((x): x is string => Boolean(x)) + .map((text) => ({ + timestamp: this.timestampProvider(), + message: text, + })) + ); + } + + flush(): InputLogEvent[] { + return this.events.splice(0); + } +} diff --git a/src/source/index.ts b/src/source/index.ts new file mode 100644 index 0000000..8bfb68e --- /dev/null +++ b/src/source/index.ts @@ -0,0 +1,4 @@ +export * from './eventSink'; +export * from './source'; +export * from './sourceCollection'; +export * from './sourceFactory'; diff --git a/src/source/source.ts b/src/source/source.ts new file mode 100644 index 0000000..123028c --- /dev/null +++ b/src/source/source.ts @@ -0,0 +1,59 @@ +import { Level, Message, MessageFormatter, SourceOptions } from '../types'; +import { EventSink } from './eventSink'; +import { InputLogEvent } from '@aws-sdk/client-cloudwatch-logs'; + +export class Source { + readonly level: Level; + readonly messageFormatter: MessageFormatter; + protected _events: EventSink; + protected _muted: boolean; + protected _disabled: boolean; + + constructor(options: SourceOptions) { + this.level = options.level; + this.messageFormatter = options.messageFormatter; + this._muted = options.muted; + this._disabled = options.disabled; + this._events = new EventSink(this, options.timestampProvider); + } + + get muted(): boolean { + return this._muted; + } + + get disabled(): boolean { + return this._disabled; + } + + get events(): EventSink['events'] { + return this._events.events; + } + + mute(): this { + this._muted = true; + return this; + } + + unmute(): this { + this._muted = false; + return this; + } + + enable(): this { + this._disabled = false; + return this; + } + + disable(): this { + this._disabled = true; + return this; + } + + async push(...messages: readonly Message[]): Promise { + return this._events.push(...messages); + } + + flush(): InputLogEvent[] { + return this._events.flush(); + } +} diff --git a/src/source/sourceCollection.ts b/src/source/sourceCollection.ts new file mode 100644 index 0000000..126344b --- /dev/null +++ b/src/source/sourceCollection.ts @@ -0,0 +1,73 @@ +import { Level, Message } from '../types'; +import { Collection } from '../collection'; +import { Source } from './source'; +import { InputLogEvent } from '@aws-sdk/client-cloudwatch-logs'; +import { wrapAsArray } from '../util'; + +export class SourceCollection extends Collection { + static wrap( + sources: Source | readonly Source[] | Collection | SourceCollection + ): SourceCollection { + return sources instanceof SourceCollection + ? sources + : new SourceCollection( + sources instanceof Collection ? sources.items : wrapAsArray(sources) + ); + } + + constructor(readonly items: readonly Source[]) { + super('Source', items); + } + + get muted(): boolean { + return ( + this.items.length > 0 && + this.items.length === this.filterBy('muted', true).items.length + ); + } + + filterByLevel(level: Level | readonly Level[]): SourceCollection { + return SourceCollection.wrap(this.filterBy('level', level)); + } + + findByLevel(level: Level): Source | undefined { + return this.findBy('level', level); + } + + protected action( + action: A + ): this { + this.items.forEach((channel) => channel[action]()); + return this; + } + + mute(): this { + this.action('mute'); + return this; + } + + unmute(): this { + this.action('unmute'); + return this; + } + + enable(): this { + this.action('enable'); + return this; + } + + disable(): this { + this.action('disable'); + return this; + } + + async push(...messages: readonly Message[]): Promise { + await Promise.all(this.items.map((source) => source.push(...messages))); + } + + flush(): InputLogEvent[] { + return ([] as readonly InputLogEvent[]).concat( + ...this.items.map((source) => source.flush()) + ); + } +} diff --git a/src/source/sourceFactory.ts b/src/source/sourceFactory.ts new file mode 100644 index 0000000..099658c --- /dev/null +++ b/src/source/sourceFactory.ts @@ -0,0 +1,52 @@ +import { + CreateSourceOptions, + CreateSourceOptionsWithoutLevel, + Level, + MessageFormatter, + SourceFactoryOptions, +} from '../types'; +import { Source } from './source'; +import { SourceCollection } from './sourceCollection'; + +export class SourceFactory { + protected readonly messageFormatter: MessageFormatter; + protected readonly muted: boolean; + protected readonly disabled: boolean; + protected readonly timestampProvider: () => number; + + constructor(options?: SourceFactoryOptions) { + this.messageFormatter = + options?.messageFormatter ?? ((message) => JSON.stringify(message)); + this.muted = options?.muted ?? false; + this.disabled = options?.disabled ?? false; + this.timestampProvider = + options?.timestampProvider ?? (() => new Date().getTime()); + } + + createSource(options: CreateSourceOptions): Source; + createSource(level: Level, options?: CreateSourceOptionsWithoutLevel): Source; + createSource( + first: Level | CreateSourceOptions, + second?: CreateSourceOptionsWithoutLevel + ): Source { + const [level, options] = + typeof first === 'string' ? [first, second] : [first.level, first]; + + return new Source({ + level, + messageFormatter: options?.messageFormatter ?? this.messageFormatter, + muted: options?.muted ?? this.muted, + disabled: options?.disabled ?? this.disabled, + timestampProvider: options?.timestampProvider ?? this.timestampProvider, + }); + } + + createSources( + levels: readonly Level[], + options?: CreateSourceOptionsWithoutLevel + ): SourceCollection { + return SourceCollection.wrap( + levels.map((level) => this.createSource(level, options)) + ); + } +} diff --git a/src/types.ts b/src/types.ts index da4b32e..9a3518c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,41 +1,115 @@ import { CloudWatchLogsClient, - CloudWatchLogsClientConfig, + InputLogEvent, } from '@aws-sdk/client-cloudwatch-logs'; +import { ChannelFactory } from './channel'; +import { SourceFactory } from './source'; +import { CollectorFactory } from './collector'; +import { Installer } from './installer'; + +// Utilities +export type NonEmptyString = T extends '' ? never : T; +export type RequiredOnly = T & Required>; +export type KeyOfType = { + [P in keyof T]: T[P] extends U ? P : never; +}[keyof T]; +type RequiredKeys = { + [K in keyof T]-?: Record extends Pick ? never : K; +}[keyof T]; +type MissingKeys> = { + [K in keyof Pick>]: Passed[K] extends Req[K] + ? never + : K; +}[keyof Pick>]; +export type RequireMissingSubset> = Pick< + Req, + MissingKeys +> & + Partial>>; +export type RequireMissing> = Record< + any, + unknown +> extends RequireMissingSubset + ? [options?: RequireMissingSubset] + : [options: RequireMissingSubset]; +export type Exact = T extends Shape + ? Exclude extends never + ? T + : never + : never; + +// Valid error levels +export type Level = 'debug' | 'info' | 'log' | 'warn' | 'error'; + +// Environment +export interface EnvironmentalOptions { + readonly storage?: StorageInterface; + readonly console?: ConsoleInterface; + readonly setInterval?: typeof setInterval; + readonly clearInterval?: typeof clearInterval; + readonly setTimeout?: typeof setTimeout; + readonly eventTarget?: EventTarget; +} + +// Source and its factory options +export type SourceOptions = { + readonly level: Level; + readonly messageFormatter: MessageFormatter; + readonly muted: boolean; + readonly disabled: boolean; + readonly timestampProvider: () => number; +}; +export type SourceFactoryOptions = Partial; +export type CreateSourceOptions = RequiredOnly, 'level'>; +export type CreateSourceOptionsWithoutLevel = Omit< + CreateSourceOptions, + 'level' +>; + +// Channel and its factory options +export type ChannelFactoryOptions = { + readonly client: ClientInterface; + readonly logGroupName: string; + readonly logStreamNameResolver?: LogStreamNameResolver; + readonly interval?: number; +}; +export type ChannelOptions = Required; // Resolve logStreamName for current user (e.g. Canvas Fingerprint) export interface LogStreamNameResolver { (): string | Promise; } -// Format message string from Error -export interface MessageFormatter { - (e: Error, info?: ErrorInfo): string | null | Promise; +// Message Formatting +export interface Message { + readonly type: T; + readonly error: E; + toJSON(): JsonMessage; } -export interface ErrorInfo { - [key: string]: any; -} - -// Options for Logger.prototype.install() -export interface InstallOptions { - logStreamNameResolver?: LogStreamNameResolver; - messageFormatter?: MessageFormatter; - ClientConstructor?: ClientConstructor; - storage?: StorageInterface; - console?: ConsoleInterface; - eventTarget?: EventTarget; +export interface MessageFormatter { + (message: Message): string | null | Promise; } - -// Valid Error Levels -export type Level = 'debug' | 'info' | 'log' | 'warn' | 'error'; +export type JsonMessage< + T extends 'console' | 'uncaught' | 'custom' | string, + F = unknown +> = { + readonly type: T; + readonly message: string; +} & F; // window.Console compatible interface +export interface ConsoleListener { + ( + message?: unknown, + ...optionalParams: readonly unknown[] + ): void | Promise; +} export interface ConsoleInterface { - debug(message?: any, ...optionalParams: any[]): void; - info(message?: any, ...optionalParams: any[]): void; - log(message?: any, ...optionalParams: any[]): void; - warn(message?: any, ...optionalParams: any[]): void; - error(message?: any, ...optionalParams: any[]): void; + debug: ConsoleListener; + info: ConsoleListener; + log: ConsoleListener; + warn: ConsoleListener; + error: ConsoleListener; } // window.localStorage and ReactNative's AsyncStorage compatible interface @@ -46,15 +120,35 @@ export interface StorageInterface { } // AWS CloudWatchLogs Client compatible interface -export interface ClientConstructor { - new (options: CloudWatchLogsClientConfig): ClientInterface; -} export interface ClientInterface { - send: CloudWatchLogsClient['send']; + readonly send: CloudWatchLogsClient['send']; } - +export type PutLogEventsPayload = { + readonly logEvents: readonly InputLogEvent[]; + readonly logGroupName: string; + readonly logStreamName: string; +}; +export type CreateLogStreamPayload = { + readonly logGroupName: string; + readonly logStreamName: string; +}; export interface AWSError extends Error { - name: string; - message: string; - expectedSequenceToken?: string; + readonly name: string; + readonly message: string; + readonly expectedSequenceToken?: string; +} +export interface StreamAlreadyExistsError extends AWSError { + readonly name: 'ResourceAlreadyExistsException'; +} +export interface UnrecoverableSequenceTokenError extends AWSError { + readonly expectedSequenceToken: undefined; +} + +// Factory Helper +export interface FactoryHelper> { + readonly install: Installer['install']; + readonly createChannel: ChannelFactory['createChannel']; + readonly createSource: SourceFactory['createSource']; + readonly createSources: SourceFactory['createSources']; + readonly createCollector: CollectorFactory['createCollector']; } diff --git a/src/util/array.ts b/src/util/array.ts new file mode 100644 index 0000000..932295c --- /dev/null +++ b/src/util/array.ts @@ -0,0 +1,3 @@ +export const wrapAsArray = (value: T | readonly T[]): T[] => { + return Array.isArray(value) ? value : [value]; +}; diff --git a/src/util/error.ts b/src/util/error.ts new file mode 100644 index 0000000..d993dc5 --- /dev/null +++ b/src/util/error.ts @@ -0,0 +1,53 @@ +import { + AWSError, + StreamAlreadyExistsError, + UnrecoverableSequenceTokenError, +} from '../types'; + +export const isValidError = (value: unknown): value is Error => { + return value instanceof Error; +}; + +export const isValidAWSError = (value: unknown): value is AWSError => { + if (!isValidError(value)) { + return false; + } + const e = value as AWSError; + return ( + typeof e.expectedSequenceToken === 'string' || + typeof e.expectedSequenceToken === 'undefined' + ); +}; + +export const isStreamAlreadyExistsError = ( + value: unknown +): value is StreamAlreadyExistsError => { + if (!isValidAWSError(value)) { + return false; + } + return value.name === 'ResourceAlreadyExistsException'; +}; + +export const isUnrecoverableSequenceTokenError = ( + value: unknown +): value is UnrecoverableSequenceTokenError => { + if (!isValidAWSError(value)) { + return false; + } + return ( + value.name !== 'DataAlreadyAcceptedException' && + value.name !== 'InvalidSequenceTokenException' && + !value.expectedSequenceToken + ); +}; + +export class BrowserPolyfillRequiredError extends Error { + constructor( + readonly missingComponents: readonly (keyof Window)[], + message?: string + ) { + super(message ?? `Missing components: ${missingComponents.join(', ')}`); + + Object.setPrototypeOf(this, BrowserPolyfillRequiredError.prototype); + } +} diff --git a/src/util/index.ts b/src/util/index.ts new file mode 100644 index 0000000..616f6a6 --- /dev/null +++ b/src/util/index.ts @@ -0,0 +1,2 @@ +export * from './array'; +export * from './error'; diff --git a/src/worker/index.ts b/src/worker/index.ts new file mode 100644 index 0000000..53708f6 --- /dev/null +++ b/src/worker/index.ts @@ -0,0 +1,2 @@ +export * from './worker'; +export * from './workerCollection'; diff --git a/src/worker/worker.ts b/src/worker/worker.ts new file mode 100644 index 0000000..234fe5c --- /dev/null +++ b/src/worker/worker.ts @@ -0,0 +1,46 @@ +import { Sender } from '../sender'; +import { InstalledEnvironment } from '../environment'; +import { Collector } from '../collector'; +import { Cache } from '../cache'; + +export class Worker { + protected readonly sender: Sender; + protected interval: ReturnType | null = null; + + constructor( + protected readonly environment: InstalledEnvironment, + readonly collector: Collector + ) { + this.sender = new Sender( + this.environment, + collector.channel, + new Cache( + this.environment.storage, + `CloudWatchFrontLogger:${collector.channelName}` + ) + ); + } + + get running(): boolean { + return this.interval !== null; + } + + start(): void { + if (!this.interval) { + this.interval = this.environment.setInterval( + () => this.tick(), + this.collector.channel.interval + ); + } + } + + stop(): void { + if (this.interval) { + this.environment.clearInterval(this.interval); + } + } + + async tick(): Promise { + await this.collector.collect(this.sender); + } +} diff --git a/src/worker/workerCollection.ts b/src/worker/workerCollection.ts new file mode 100644 index 0000000..4a2bf02 --- /dev/null +++ b/src/worker/workerCollection.ts @@ -0,0 +1,49 @@ +import { Worker } from './worker'; +import { wrapAsArray } from '../util'; +import { Collection } from '../collection'; +import { CollectorCollection } from '../collector'; +import { ChannelCollection } from '../channel'; +import { SourceCollection } from '../source'; + +export class WorkerCollection extends Collection { + static wrap( + workers: Worker | readonly Worker[] | Collection | WorkerCollection + ): WorkerCollection { + return workers instanceof WorkerCollection + ? workers + : new WorkerCollection( + workers instanceof Collection ? workers.items : wrapAsArray(workers) + ); + } + + constructor(readonly items: readonly Worker[]) { + super('Worker', items); + } + + get collectors(): CollectorCollection { + return CollectorCollection.wrap( + this.items.map((worker) => worker.collector) + ); + } + + get channels(): ChannelCollection { + return this.collectors.channels; + } + + get sources(): SourceCollection { + return this.collectors.sources; + } + + protected action(action: A): this { + this.items.forEach((channel) => channel[action]()); + return this; + } + + start(): void { + this.action('start'); + } + + stop(): void { + this.action('stop'); + } +} diff --git a/tests/cache.test.ts b/tests/cache.test.ts new file mode 100644 index 0000000..cd1b014 --- /dev/null +++ b/tests/cache.test.ts @@ -0,0 +1,33 @@ +import { Cache } from '../src'; +import { DummyStorage } from './stub'; + +describe('Cache', () => { + const storage = new DummyStorage(); + const cache = new Cache(storage, 'app'); + + it('should set/get/remove item', async () => { + await cache.setItem('foo', 'bar'); + expect(await cache.getItem('foo')).toBe('bar'); + await cache.removeItem('foo'); + expect(await cache.getItem('foo')).toBe(null); + }); + + it('should remove items at once', async () => { + await cache.setItem('foo1', 'bar'); + await cache.setItem('foo2', 'bar'); + await cache.setItem('foo3', 'bar'); + await cache.removeItem('foo1', 'foo2', 'foo3'); + expect(await cache.getItem('foo1')).toBe(null); + expect(await cache.getItem('foo2')).toBe(null); + expect(await cache.getItem('foo3')).toBe(null); + }); + + it('should prefix items', async () => { + await cache.setItem('foo', 'bar'); + expect(storage.getItem('app:foo')).toBe('bar'); + + const wrappedCache = new Cache(cache, 'nested'); + await wrappedCache.setItem('foo', 'bar'); + expect(storage.getItem('app:nested:foo')).toBe('bar'); + }); +}); diff --git a/tests/channel/channel.test.ts b/tests/channel/channel.test.ts new file mode 100644 index 0000000..82e9897 --- /dev/null +++ b/tests/channel/channel.test.ts @@ -0,0 +1,60 @@ +import { Channel, ChannelOptions } from '../../src'; +import { CloudWatchLogsClient } from '@aws-sdk/client-cloudwatch-logs'; + +jest.mock('@aws-sdk/client-cloudwatch-logs'); +const CloudWatchLogsClientMock = CloudWatchLogsClient as jest.Mock; + +const createDefaultOptions = (): ChannelOptions => ({ + client: new CloudWatchLogsClientMock(), + interval: 10000, + logGroupName: 'testGroup', + logStreamNameResolver: async () => 'testStream', +}); + +describe('channel', () => { + it('should create put log events payload', async () => { + const channel = new Channel('example', createDefaultOptions()); + expect( + await channel.createPutLogEventsPayload([ + { message: 'foo', timestamp: 123 }, + { message: 'bar', timestamp: 456 }, + ]) + ).toEqual({ + logGroupName: 'testGroup', + logStreamName: 'testStream', + logEvents: [ + { message: 'foo', timestamp: 123 }, + { message: 'bar', timestamp: 456 }, + ], + }); + }); + + it('should create put log events payload with nextSequenceToken', async () => { + const channel = new Channel('example', createDefaultOptions()); + expect( + await channel.createPutLogEventsPayload( + [ + { message: 'foo', timestamp: 123 }, + { message: 'bar', timestamp: 456 }, + ], + 'abc' + ) + ).toEqual({ + logGroupName: 'testGroup', + logStreamName: 'testStream', + logEvents: [ + { message: 'foo', timestamp: 123 }, + { message: 'bar', timestamp: 456 }, + ], + sequenceToken: 'abc', + }); + }); + + it('should create create log stream payload', async () => { + const channel = new Channel('example', createDefaultOptions()); + expect(await channel.createCreateLogStreamPayload()).toEqual({ + logGroupName: 'testGroup', + logStreamName: 'testStream', + }); + }); +}); diff --git a/tests/channel/channelCollection.test.ts b/tests/channel/channelCollection.test.ts new file mode 100644 index 0000000..7f10863 --- /dev/null +++ b/tests/channel/channelCollection.test.ts @@ -0,0 +1,61 @@ +import { Channel, ChannelCollection, ChannelOptions } from '../../src'; +import { CloudWatchLogsClient } from '@aws-sdk/client-cloudwatch-logs'; + +jest.mock('@aws-sdk/client-cloudwatch-logs'); +const CloudWatchLogsClientMock = CloudWatchLogsClient as jest.Mock; + +const createDefaultChannelOptions = (): ChannelOptions => ({ + client: new CloudWatchLogsClientMock(), + interval: 10000, + logGroupName: 'testGroup', + logStreamNameResolver: () => 'testStream', +}); + +const createChannels = () => + new ChannelCollection([ + new Channel('foo-x', { + ...createDefaultChannelOptions(), + logGroupName: 'g10', + }), + new Channel('foo-y', { + ...createDefaultChannelOptions(), + logGroupName: 'g10', + }), + new Channel('foo-z', { + ...createDefaultChannelOptions(), + logGroupName: 'g20', + }), + new Channel('bar-x', { + ...createDefaultChannelOptions(), + logGroupName: 'g20', + }), + new Channel('bar-y', { + ...createDefaultChannelOptions(), + logGroupName: 'g21', + }), + ]); + +describe('channelCollection', () => { + it('should filter by name prefix', () => { + const channels = createChannels().filterByNamePrefix('foo').items; + expect(channels).toHaveLength(3); + expect(channels[0].name).toBe('foo-x'); + expect(channels[1].name).toBe('foo-y'); + expect(channels[2].name).toBe('foo-z'); + }); + + it('should filter by log group name', () => { + const channels = createChannels().filterByLogGroupName('g20').items; + expect(channels).toHaveLength(2); + expect(channels[0].name).toBe('foo-z'); + expect(channels[1].name).toBe('bar-x'); + }); + + it('should find by name', () => { + const channels = createChannels(); + expect(channels.findByName('foo-x')?.name).toBe('foo-x'); + expect(channels.findByName('foo-%')).toBeUndefined(); + }); + + // TODO +}); diff --git a/tests/channel/channelFactory.test.ts b/tests/channel/channelFactory.test.ts new file mode 100644 index 0000000..d40a409 --- /dev/null +++ b/tests/channel/channelFactory.test.ts @@ -0,0 +1,52 @@ +import { ChannelFactory, ChannelFactoryOptions } from '../../src'; +import { CloudWatchLogsClient } from '@aws-sdk/client-cloudwatch-logs'; + +jest.mock('@aws-sdk/client-cloudwatch-logs'); +const CloudWatchLogsClientMock = CloudWatchLogsClient as jest.Mock; + +const createDefaultChannelFactoryOptions = (): ChannelFactoryOptions => ({ + client: new CloudWatchLogsClientMock(), + logGroupName: 'testGroup', +}); + +describe('channelFactory', () => { + it('should create with global default values', async () => { + const options = createDefaultChannelFactoryOptions(); + const factory = new ChannelFactory(options); + const channel = factory.createChannel('channel'); + expect(channel.name).toBe('channel'); + expect(channel.client).toBe(options.client); + expect(channel.logGroupName).toBe('testGroup'); + expect(await channel.logStreamNameResolver()).toBe('anonymous'); + expect(channel.interval).toBe(3000); + }); + + it('should create with factory default values', async () => { + const options = createDefaultChannelFactoryOptions(); + const factory = new ChannelFactory({ + ...options, + interval: 1000, + logStreamNameResolver: () => 'testStream', + }); + const channel = factory.createChannel('channel'); + expect(channel.name).toBe('channel'); + expect(channel.client).toBe(options.client); + expect(channel.logGroupName).toBe('testGroup'); + expect(await channel.logStreamNameResolver()).toBe('testStream'); + expect(channel.interval).toBe(1000); + }); + + it('should create with artificial values', async () => { + const options = { + client: new CloudWatchLogsClientMock(), + logGroupName: 'testGroup', + }; + const factory = new ChannelFactory(); + const channel = factory.createChannel('channel', options); + expect(channel.name).toBe('channel'); + expect(channel.client).toBe(options.client); + expect(channel.logGroupName).toBe('testGroup'); + expect(await channel.logStreamNameResolver()).toBe('anonymous'); + expect(channel.interval).toBe(3000); + }); +}); diff --git a/tests/handler.test.ts b/tests/handler.test.ts new file mode 100644 index 0000000..c264480 --- /dev/null +++ b/tests/handler.test.ts @@ -0,0 +1,49 @@ +import { Handler, SourceCollection, ConsoleMessage } from '../src'; +import { DummyConsole } from './stub'; + +jest.mock('./stub'); +const ConsoleMock = DummyConsole as jest.Mock; + +jest.mock('../src/channel/channelCollection'); +const SourceCollectionMock = + SourceCollection as unknown as jest.Mock; + +const prepareDeps = ({ muted = false } = {}) => { + const originalConsole = new ConsoleMock(); + const sources = new SourceCollectionMock(); + const filteredSources = new SourceCollectionMock(); + const mutedGetter = jest.fn(() => muted); + sources.filterByLevel = jest.fn(() => filteredSources); + Object.defineProperty(filteredSources, 'muted', { + get: mutedGetter, + }); + filteredSources.push = jest.fn(); + return { originalConsole, sources, filteredSources, mutedGetter }; +}; + +describe('Handler', () => { + it('should call console and push messages', async () => { + const deps = prepareDeps(); + const handler = new Handler(deps.originalConsole, deps.sources); + await handler.warn('msg', 'foo', 'bar'); + expect(deps.mutedGetter).toBeCalledTimes(1); + expect(deps.originalConsole.warn).toBeCalledTimes(1); + expect(deps.originalConsole.warn).toBeCalledWith('msg', 'foo', 'bar'); + expect(deps.filteredSources.push).toBeCalledTimes(1); + expect(deps.filteredSources.push).toBeCalledWith( + new ConsoleMessage('warn', 'msg', ['foo', 'bar']) + ); + }); + + it('should ignore console when muted', async () => { + const deps = prepareDeps({ muted: true }); + const handler = new Handler(deps.originalConsole, deps.sources); + await handler.warn('msg', 'foo', 'bar'); + expect(deps.mutedGetter).toBeCalledTimes(1); + expect(deps.originalConsole.warn).not.toBeCalled(); + expect(deps.filteredSources.push).toBeCalledTimes(1); + expect(deps.filteredSources.push).toBeCalledWith( + new ConsoleMessage('warn', 'msg', ['foo', 'bar']) + ); + }); +}); diff --git a/tests/integration.test.ts b/tests/integration.test.ts new file mode 100644 index 0000000..a0a8b96 --- /dev/null +++ b/tests/integration.test.ts @@ -0,0 +1,330 @@ +// import { PutLogEventsCommand } from '@aws-sdk/client-cloudwatch-logs'; +import { + ChannelFactory, + CollectorFactory, + SourceFactory, + Environment, + Installer, + Logger, +} from '../src'; + +import { + // DummyAWSError, + DummyClient, + DummyConsole, + DummyEventTarget, + DummyStorage, +} from './stub'; + +let logger: Logger; +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore +let storage: DummyStorage; +let globalConsole: DummyConsole; +let eventTarget: DummyEventTarget; +let client: DummyClient; +const originalDateConstructor = Date; + +const freezeDate = (at: number): void => { + const date = new originalDateConstructor(at); + jest + .spyOn(global, 'Date') + .mockImplementation(() => date as unknown as string); +}; + +const install = (): Logger => { + const environment = new Environment({ + storage: (storage = new DummyStorage()), + console: (globalConsole = new DummyConsole()), + eventTarget: (eventTarget = new DummyEventTarget()), + setInterval: (() => 0) as unknown as typeof global.setInterval, + }); + const channelFactory = new ChannelFactory({ + client: (client = new DummyClient()), + logGroupName: 'group', + }); + const sourceFactory = new SourceFactory(); + return new Installer(environment).install( + new CollectorFactory().createCollector( + channelFactory.createChannel('app'), + sourceFactory.createSources(['warn', 'error']) + ) + ); +}; + +beforeEach(() => { + jest.useFakeTimers(); + freezeDate(0); + logger = install(); +}); + +afterEach(() => { + jest.useRealTimers(); + global.Date = originalDateConstructor; +}); + +describe('Collecting errors via handler', (): void => { + it('should receive from uncaught', async (): Promise => { + await eventTarget.listeners.error({ + error: new Error('Something went wrong'), + } as unknown as ErrorEvent); + expect(logger.sources.items[0].events).toHaveLength(0); + expect(logger.sources.items[1].events).toStrictEqual([ + { + message: JSON.stringify({ + type: 'uncaught', + message: 'Error: Something went wrong', + }), + timestamp: 0, + }, + ]); + }); + + it('should receive from console', async (): Promise => { + await globalConsole.error(new Error('Something went wrong')); + await globalConsole.warn('Something got worse'); + expect(logger.sources.items[0].events).toStrictEqual([ + { + message: JSON.stringify({ + type: 'console', + message: 'Something got worse', + level: 'warn', + params: [], + }), + timestamp: 0, + }, + ]); + expect(logger.sources.items[1].events).toStrictEqual([ + { + message: JSON.stringify({ + type: 'console', + message: 'Error: Something went wrong', + level: 'error', + params: [], + }), + timestamp: 0, + }, + ]); + }); + + it('should receive from custom trigger', async (): Promise => { + await logger.notify('error', new Error('Something went wrong')); + await logger.notify('warn', 'Something got worse'); + expect(logger.sources.items[0].events).toStrictEqual([ + { + message: JSON.stringify({ + type: 'custom', + message: 'Something got worse', + params: [], + }), + timestamp: 0, + }, + ]); + expect(logger.sources.items[1].events).toStrictEqual([ + { + message: JSON.stringify({ + type: 'custom', + message: 'Error: Something went wrong', + params: [], + }), + timestamp: 0, + }, + ]); + }); +}); + +describe('Creating logStream', (): void => { + it('should create new logStream', async (): Promise => { + // const name = await logger.channels.items[0].name; + // expect(name).toBe('app'); + // expect(await storage.getItem('CloudWatchFrontLogger:logStreamName')).toBe('app'); + // expect(logger.sources.items[0].events.sink['example/abc123']).toStrictEqual([]); + }); + // + // it('should return cached logStream', async (): Promise => { + // await storage.setItem('CloudWatchFrontLogger:logStreamName', 'abc123'); + // const name = await (logger as any).getLogStreamName(); + // expect(name).toBe('abc123'); + // expect((logger as any).client.sink['example/abc123']).toBeUndefined(); + // }); + // + // it('should recover from ResourceAlreadyExistsException', async (): Promise => { + // (logger as any).client.send = jest + // .fn() + // .mockRejectedValue( + // new DummyAWSError( + // 'Duplicate LogStream', + // 'ResourceAlreadyExistsException' + // ) + // ); + // const name = await (logger as any).getLogStreamName(); + // expect(name).toBe('abc123'); + // expect(await storage.getItem('CloudWatchFrontLogger:logStreamName')).toBe( + // 'abc123' + // ); + // expect((logger as any).client.sink['example/abc123']).toBeUndefined(); + // }); + // + // it('should halt when other error occurred', async (): Promise => { + // (logger as any).client.send = jest + // .fn() + // .mockRejectedValue( + // new DummyAWSError('Something went wrong', 'UnknownException') + // ); + // const name = await (logger as any).getLogStreamName(); + // expect(name).toBeNull(); + // expect(globalConsole.messages).toStrictEqual([ + // { + // message: new DummyAWSError('Something went wrong', 'UnknownException'), + // level: 'error', + // }, + // ]); + // expect( + // await storage.getItem('CloudWatchFrontLogger:logStreamName') + // ).toBeNull(); + // }); + // + // it('should fallback to default logStreamName', async (): Promise => { + // install({ + // logStreamNameResolver: undefined, + // }); + // const name = await (logger as any).getLogStreamName(); + // expect(name).toBe('anonymous'); + // expect(await storage.getItem('CloudWatchFrontLogger:logStreamName')).toBe( + // 'anonymous' + // ); + // expect((logger as any).client.sink['example/anonymous']).toStrictEqual([]); + // }); +}); + +describe('Sending logs', (): void => { + it('should send events', async (): Promise => { + // await globalConsole.error(new Error('Something went wrong')); + // await globalConsole.warn('Something got worse'); + // await logger.workers.items[0].tick(); + // expect((logger as any).events).toStrictEqual([]); + // expect((logger as any).client.sink['example/abc123']).toStrictEqual([ + // { + // message: 'Error 1', + // timestamp: 1, + // }, + // { + // message: 'Error 2', + // timestamp: 2, + // }, + // ]); + // expect(storage.getItem('CloudWatchFrontLogger:sequenceToken')).toBe( + // 'SEQUENCE_TOKEN_#' + // ); + }); + // + // it('should reuse previous nextSequenceToken', async (): Promise => { + // (logger as any).events.push({ + // message: 'Error 1', + // timestamp: 1, + // }); + // await logger.onInterval(); + // (logger as any).events.push({ + // message: 'Error 2', + // timestamp: 2, + // }); + // await logger.onInterval(); + // expect(storage.getItem('CloudWatchFrontLogger:sequenceToken')).toBe( + // 'SEQUENCE_TOKEN_##' + // ); + // }); + // + // it('should recover from InvalidSequenceTokenException', async (): Promise => { + // (logger as any).events.push({ + // message: 'Error 1', + // timestamp: 1, + // }); + // + // await logger.onInterval(); + // const originalSend = (logger as any).client.send; + // (logger as any).client.send = jest.fn((command) => { + // if (!(command instanceof PutLogEventsCommand)) { + // return originalSend.call(logger, command); + // } + // (logger as any).client.sink[ + // `${command.input.logGroupName}/${command.input.logStreamName}` + // ].push(...(command.input?.logEvents ?? [])); + // return Promise.reject( + // new DummyAWSError( + // '... The next expected sequenceToken is ...', + // 'InvalidSequenceTokenException', + // 'SEQUENCE_TOKEN_123' + // ) + // ); + // }); + // (logger as any).events.push({ + // message: 'Error 2', + // timestamp: 2, + // }); + // + // await logger.onInterval(); + // expect((logger as any).events).toStrictEqual([ + // { + // message: 'Error 2', + // timestamp: 2, + // }, + // ]); + // expect((logger as any).client.sink['example/abc123']).toStrictEqual([ + // { + // message: 'Error 1', + // timestamp: 1, + // }, + // { + // message: 'Error 2', + // timestamp: 2, + // }, + // ]); + // expect(storage.getItem('CloudWatchFrontLogger:sequenceToken')).toBe( + // 'SEQUENCE_TOKEN_123' + // ); + // }); + // + // it('should halt when other error occurred', async (): Promise => { + // (logger as any).events.push({ + // message: 'Error 1', + // timestamp: 1, + // }); + // + // await logger.onInterval(); + // + // const originalSend = (logger as any).client.send; + // (logger as any).client.send = jest.fn((command) => { + // if (!(command instanceof PutLogEventsCommand)) { + // return originalSend.call(logger, command); + // } + // + // (logger as any).client.sink[ + // `${command.input.logGroupName}/${command.input.logStreamName}` + // ].push(...(command.input?.logEvents ?? [])); + // return Promise.reject( + // new DummyAWSError( + // '... The next expected sequenceToken is: ????? ...', + // 'InvalidSequenceTokenException' + // ) + // ); + // }); + // (logger as any).events.push({ + // message: 'Error 2', + // timestamp: 2, + // }); + // + // await logger.onInterval(); + // expect((logger as any).events).toStrictEqual([]); + // expect((logger as any).client.sink['example/abc123']).toStrictEqual([ + // { + // message: 'Error 1', + // timestamp: 1, + // }, + // { + // message: 'Error 2', + // timestamp: 2, + // }, + // ]); + // expect(storage.getItem('CloudWatchFrontLogger:sequenceToken')).toBeNull(); + // }); +}); diff --git a/tests/main.test.ts b/tests/main.test.ts deleted file mode 100644 index 9dae5d8..0000000 --- a/tests/main.test.ts +++ /dev/null @@ -1,319 +0,0 @@ -import { PutLogEventsCommand } from '@aws-sdk/client-cloudwatch-logs'; -import { advanceTo } from 'jest-date-mock'; -import { Logger } from '../src'; - -import { - DummyAWSError, - DummyClient, - DummyConsole, - DummyEventTarget, - DummyStorage, -} from './stub'; -import { InstallOptions } from '../src'; - -let logger: Logger; -let globalConsole: DummyConsole; -let storage: DummyStorage; -let eventTarget: DummyEventTarget; - -jest.useFakeTimers('legacy'); - -const install = (options: Partial = {}): void => { - logger = new Logger('key', 'secret', 'ap-northeast-1', 'example'); - logger.install({ - ClientConstructor: DummyClient, - console: (globalConsole = new DummyConsole()), - storage: (storage = new DummyStorage()), - eventTarget: (eventTarget = new DummyEventTarget()), - logStreamNameResolver: () => 'abc123', - ...options, - }); - advanceTo(0); -}; - -beforeEach(() => install()); - -describe('Cache storage', (): void => { - it('should store with prefixed key', async (): Promise => { - await (logger as any).setCache('key', 'value'); - expect(storage.storage['CloudWatchFrontLogger:key']).toBe('value'); - }); - - it('should retrieve value', async (): Promise => { - await (logger as any).setCache('key', 'value'); - expect(await (logger as any).getCache('key')).toBe('value'); - }); - - it('should unset value', async (): Promise => { - await (logger as any).setCache('key', 'value'); - expect(await (logger as any).deleteCache('key')).toBeUndefined(); - }); -}); - -describe('Collecting errors', (): void => { - it('should receive from uncaught', async (): Promise => { - await eventTarget.listeners.error(new Error('Something went wrong') as any); - expect((logger as any).events).toStrictEqual([ - { - message: JSON.stringify({ - message: 'Something went wrong', - type: 'uncaught', - }), - timestamp: 0, - }, - ]); - }); - - it('should receive from console', async (): Promise => { - await globalConsole.error(new Error('Something went wrong') as any); - expect((logger as any).events).toStrictEqual([ - { - message: JSON.stringify({ - message: 'Error: Something went wrong', - type: 'console', - level: 'error', - }), - timestamp: 0, - }, - ]); - }); - - it('should receive from custom trigger', async (): Promise => { - await logger.onError(new Error('Something went wrong'), { type: 'custom' }); - expect((logger as any).events).toStrictEqual([ - { - message: JSON.stringify({ - message: 'Something went wrong', - type: 'custom', - }), - timestamp: 0, - }, - ]); - }); - - it('should use custom formatter', async (): Promise => { - install({ - messageFormatter: (e, info) => - `[ERROR] ${e.message} `, - }); - await logger.onError(new Error('Something went wrong'), { type: 'custom' }); - expect((logger as any).events).toStrictEqual([ - { - message: '[ERROR] Something went wrong ', - timestamp: 0, - }, - ]); - }); - - it('should halt when disabled', async (): Promise => { - logger.disable(); - await logger.onError(new Error('Something went wrong')); - expect((logger as any).events).toStrictEqual([]); - }); -}); - -describe('Creating logStream', (): void => { - it('should create new logStream', async (): Promise => { - const name = await (logger as any).getLogStreamName(); - expect(name).toBe('abc123'); - expect(await storage.getItem('CloudWatchFrontLogger:logStreamName')).toBe( - 'abc123' - ); - expect((logger as any).client.sink['example/abc123']).toStrictEqual([]); - }); - - it('should return cached logStream', async (): Promise => { - await storage.setItem('CloudWatchFrontLogger:logStreamName', 'abc123'); - const name = await (logger as any).getLogStreamName(); - expect(name).toBe('abc123'); - expect((logger as any).client.sink['example/abc123']).toBeUndefined(); - }); - - it('should recover from ResourceAlreadyExistsException', async (): Promise => { - (logger as any).client.send = jest - .fn() - .mockRejectedValue( - new DummyAWSError( - 'Duplicate LogStream', - 'ResourceAlreadyExistsException' - ) - ); - const name = await (logger as any).getLogStreamName(); - expect(name).toBe('abc123'); - expect(await storage.getItem('CloudWatchFrontLogger:logStreamName')).toBe( - 'abc123' - ); - expect((logger as any).client.sink['example/abc123']).toBeUndefined(); - }); - - it('should halt when other error occurred', async (): Promise => { - (logger as any).client.send = jest - .fn() - .mockRejectedValue( - new DummyAWSError('Something went wrong', 'UnknownException') - ); - const name = await (logger as any).getLogStreamName(); - expect(name).toBeNull(); - expect(globalConsole.messages).toStrictEqual([ - { - message: new DummyAWSError('Something went wrong', 'UnknownException'), - level: 'error', - }, - ]); - expect( - await storage.getItem('CloudWatchFrontLogger:logStreamName') - ).toBeNull(); - }); - - it('should fallback to default logStreamName', async (): Promise => { - install({ - logStreamNameResolver: undefined, - }); - const name = await (logger as any).getLogStreamName(); - expect(name).toBe('anonymous'); - expect(await storage.getItem('CloudWatchFrontLogger:logStreamName')).toBe( - 'anonymous' - ); - expect((logger as any).client.sink['example/anonymous']).toStrictEqual([]); - }); -}); - -describe('Sending logs', (): void => { - it('should send events', async (): Promise => { - (logger as any).events.push( - { - message: 'Error 1', - timestamp: 1, - }, - { - message: 'Error 2', - timestamp: 2, - } - ); - await logger.onInterval(); - expect((logger as any).events).toStrictEqual([]); - expect((logger as any).client.sink['example/abc123']).toStrictEqual([ - { - message: 'Error 1', - timestamp: 1, - }, - { - message: 'Error 2', - timestamp: 2, - }, - ]); - expect(storage.getItem('CloudWatchFrontLogger:sequenceToken')).toBe( - 'SEQUENCE_TOKEN_#' - ); - }); - - it('should reuse previous nextSequenceToken', async (): Promise => { - (logger as any).events.push({ - message: 'Error 1', - timestamp: 1, - }); - await logger.onInterval(); - (logger as any).events.push({ - message: 'Error 2', - timestamp: 2, - }); - await logger.onInterval(); - expect(storage.getItem('CloudWatchFrontLogger:sequenceToken')).toBe( - 'SEQUENCE_TOKEN_##' - ); - }); - - it('should recover from InvalidSequenceTokenException', async (): Promise => { - (logger as any).events.push({ - message: 'Error 1', - timestamp: 1, - }); - - await logger.onInterval(); - const originalSend = (logger as any).client.send; - (logger as any).client.send = jest.fn((command) => { - if (!(command instanceof PutLogEventsCommand)) { - return originalSend.call(logger, command); - } - (logger as any).client.sink[ - `${command.input.logGroupName}/${command.input.logStreamName}` - ].push(...(command.input?.logEvents ?? [])); - return Promise.reject( - new DummyAWSError( - '... The next expected sequenceToken is ...', - 'InvalidSequenceTokenException', - 'SEQUENCE_TOKEN_123' - ) - ); - }); - (logger as any).events.push({ - message: 'Error 2', - timestamp: 2, - }); - - await logger.onInterval(); - expect((logger as any).events).toStrictEqual([ - { - message: 'Error 2', - timestamp: 2, - }, - ]); - expect((logger as any).client.sink['example/abc123']).toStrictEqual([ - { - message: 'Error 1', - timestamp: 1, - }, - { - message: 'Error 2', - timestamp: 2, - }, - ]); - expect(storage.getItem('CloudWatchFrontLogger:sequenceToken')).toBe( - 'SEQUENCE_TOKEN_123' - ); - }); - - it('should halt when other error occurred', async (): Promise => { - (logger as any).events.push({ - message: 'Error 1', - timestamp: 1, - }); - - await logger.onInterval(); - - const originalSend = (logger as any).client.send; - (logger as any).client.send = jest.fn((command) => { - if (!(command instanceof PutLogEventsCommand)) { - return originalSend.call(logger, command); - } - - (logger as any).client.sink[ - `${command.input.logGroupName}/${command.input.logStreamName}` - ].push(...(command.input?.logEvents ?? [])); - return Promise.reject( - new DummyAWSError( - '... The next expected sequenceToken is: ????? ...', - 'InvalidSequenceTokenException' - ) - ); - }); - (logger as any).events.push({ - message: 'Error 2', - timestamp: 2, - }); - - await logger.onInterval(); - expect((logger as any).events).toStrictEqual([]); - expect((logger as any).client.sink['example/abc123']).toStrictEqual([ - { - message: 'Error 1', - timestamp: 1, - }, - { - message: 'Error 2', - timestamp: 2, - }, - ]); - expect(storage.getItem('CloudWatchFrontLogger:sequenceToken')).toBeNull(); - }); -}); diff --git a/tests/sender.test.ts b/tests/sender.test.ts new file mode 100644 index 0000000..12cddc4 --- /dev/null +++ b/tests/sender.test.ts @@ -0,0 +1,110 @@ +import { Sender, InstalledEnvironment, Channel, Cache } from '../src'; +import { DummyClient } from './stub'; +import { CreateLogStreamCommand } from '@aws-sdk/client-cloudwatch-logs'; + +jest.mock('../src/environment'); +jest.mock('../src/cache'); +jest.mock('./stub'); + +const EnvironmentMock = InstalledEnvironment as jest.Mock; +const CacheMock = Cache as jest.Mock; +const ClientMock = DummyClient as jest.Mock; + +type Writable = { -readonly [P in keyof T]: Writable }; +const writable = (obj: T): Writable => obj; + +const prepareDeps = () => { + const channel = new Channel('testing', { + logGroupName: 'logGroup', + client: new ClientMock(), + logStreamNameResolver: async () => 'newLogStream', + interval: 1, + }); + const environment = new EnvironmentMock(); + writable(environment).originalConsole = { + error: jest.fn(), + } as unknown as Console; + return { + environment, + cache: new CacheMock(), + channel, + }; +}; + +const callGetLogStreamName = (sender: Sender): Promise => { + return (sender as any).getLogStreamName(); +}; + +describe('Sender.getLogStreamName()', () => { + it('should skip creating when cached', async () => { + const { environment, channel, cache } = prepareDeps(); + + cache.getItem = jest.fn(async () => 'cachedLogStream'); + + expect( + await callGetLogStreamName(new Sender(environment, channel, cache)) + ).toBe('cachedLogStream'); + expect(cache.getItem).toBeCalledWith('logStreamName'); + expect(cache.getItem).toBeCalledTimes(1); + }); + + it('should succeed creating', async () => { + const { environment, channel, cache } = prepareDeps(); + + writable(channel).client.send = jest.fn( + (payload: CreateLogStreamCommand) => { + expect(payload.input.logGroupName).toBe('logGroup'); + expect(payload.input.logStreamName).toBe('newLogStream'); + } + ); + + expect( + await callGetLogStreamName(new Sender(environment, channel, cache)) + ).toBe('newLogStream'); + expect(cache.getItem).toBeCalledWith('logStreamName'); + expect(cache.getItem).toBeCalledTimes(1); + expect(channel.client.send).toBeCalledTimes(1); + expect(cache.setItem).toBeCalledWith('logStreamName', 'newLogStream'); + expect(cache.setItem).toBeCalledTimes(1); + }); + + it('should refresh when common errors', async () => { + const { environment, channel, cache } = prepareDeps(); + + writable(channel).client.send = jest.fn(async () => { + throw new Error('Error!'); + }); + + expect( + await callGetLogStreamName(new Sender(environment, channel, cache)) + ).toBeNull(); + expect(cache.getItem).toBeCalledWith('logStreamName'); + expect(cache.getItem).toBeCalledTimes(1); + expect(channel.client.send).toBeCalledTimes(1); + expect(environment.originalConsole.error).toBeCalledWith( + new Error('Error!') + ); + expect(environment.originalConsole.error).toBeCalledTimes(1); + expect(cache.removeItem).toBeCalledWith('logStreamName', 'sequenceToken'); + expect(cache.removeItem).toBeCalledTimes(1); + }); + + it('should recover from ResourceAlreadyExistsException', async () => { + const { environment, channel, cache } = prepareDeps(); + + writable(channel).client.send = jest.fn(async () => { + const error = new Error('Error!'); + error.name = 'ResourceAlreadyExistsException'; + throw error; + }); + + expect( + await callGetLogStreamName(new Sender(environment, channel, cache)) + ).toBe('newLogStream'); + expect(cache.getItem).toBeCalledWith('logStreamName'); + expect(cache.getItem).toBeCalledTimes(1); + expect(channel.client.send).toBeCalledTimes(1); + expect(cache.setItem).toBeCalledWith('logStreamName', 'newLogStream'); + expect(cache.setItem).toBeCalledTimes(1); + }); +}); diff --git a/tests/source/eventSink.test.ts b/tests/source/eventSink.test.ts new file mode 100644 index 0000000..28aa9eb --- /dev/null +++ b/tests/source/eventSink.test.ts @@ -0,0 +1,82 @@ +import { + Source, + ConsoleMessage, + EventSink, + CustomMessage, + ErrorEventMessage, +} from '../../src'; + +jest.mock('../../src/source/source'); +const SourceMock = Source as jest.Mock; + +const createDeps = ({ disabled = false } = {}) => { + const messageFormatter: Source['messageFormatter'] = jest.fn((e) => + e.toJSON().message ? `${e.toJSON().message}(type=${e.toJSON().type})` : null + ); + SourceMock.mockImplementationOnce( + () => ({ messageFormatter } as unknown as Source) + ); + const source = new SourceMock(); + const disabledGetter = jest.fn(() => disabled); + Object.defineProperty(source, 'disabled', { + get: disabledGetter, + }); + return { + source, + disabledGetter, + messageFormatter, + timestampProvider: jest.fn(() => 123), + }; +}; + +describe('eventSink', () => { + it('should ignore when disabled', async () => { + const deps = createDeps({ disabled: true }); + const sink = new EventSink(deps.source, deps.timestampProvider); + await sink.push(new ConsoleMessage('warn', 'Error!', [])); + expect(sink.flush()).toHaveLength(0); + expect(deps.disabledGetter).toBeCalledTimes(1); + expect(deps.messageFormatter).toBeCalledTimes(0); + expect(deps.timestampProvider).toBeCalledTimes(0); + }); + + it('should filter empty messages', async () => { + const deps = createDeps(); + const sink = new EventSink(deps.source, deps.timestampProvider); + await sink.push( + new ConsoleMessage('warn', 'Console Error', []), + new ConsoleMessage('warn', '', []), + new ErrorEventMessage({ + error: new Error('Error Event'), + } as unknown as ErrorEvent), + new ErrorEventMessage({ error: new Error('') } as unknown as ErrorEvent), + new CustomMessage(new Error('Custom Error'), []), + new CustomMessage(new Error(''), []) + ); + const messages = sink.flush(); + expect(messages).toHaveLength(5); + expect(messages).toEqual([ + { message: 'Console Error(type=console)', timestamp: 123 }, + { message: 'Error: Error Event(type=uncaught)', timestamp: 123 }, + { message: 'Error(type=uncaught)', timestamp: 123 }, + { message: 'Error: Custom Error(type=custom)', timestamp: 123 }, + { message: 'Error(type=custom)', timestamp: 123 }, + ]); + expect(deps.disabledGetter).toBeCalledTimes(1); + expect(deps.messageFormatter).toBeCalledTimes(6); + expect(deps.timestampProvider).toBeCalledTimes(5); + }); + + it('should clear after flush', async () => { + const deps = createDeps(); + const sink = new EventSink(deps.source, deps.timestampProvider); + await sink.push(new ConsoleMessage('warn', 'Console Error', [])); + let messages = sink.flush(); + expect(messages).toHaveLength(1); + expect(messages).toEqual([ + { message: 'Console Error(type=console)', timestamp: 123 }, + ]); + messages = sink.flush(); + expect(messages).toHaveLength(0); + }); +}); diff --git a/tests/source/source.test.ts b/tests/source/source.test.ts new file mode 100644 index 0000000..2f5a354 --- /dev/null +++ b/tests/source/source.test.ts @@ -0,0 +1,74 @@ +import { Source, SourceOptions, ConsoleMessage, EventSink } from '../../src'; +import { InputLogEvent } from '@aws-sdk/client-cloudwatch-logs'; + +jest.mock('../../src/source/eventSink'); +const EventSinkMock = EventSink as jest.Mock; + +const createDefaultSourceOptions = (options?: { + muted?: boolean; + disabled?: boolean; + now?: number; +}): SourceOptions => ({ + level: 'error', + muted: options?.muted ?? false, + disabled: options?.disabled ?? false, + messageFormatter: (e) => String(e.error), + timestampProvider: () => options?.now ?? new Date().getTime(), +}); + +describe('source', () => { + it('should mute', () => { + const source = new Source(createDefaultSourceOptions()); + source.mute(); + expect(source.muted).toBe(true); + }); + + it('should unmute', () => { + const source = new Source(createDefaultSourceOptions({ muted: true })); + source.unmute(); + expect(source.muted).toBe(false); + }); + + it('should disable', () => { + const source = new Source(createDefaultSourceOptions()); + source.disable(); + expect(source.disabled).toBe(true); + }); + + it('should enable', () => { + const source = new Source(createDefaultSourceOptions({ disabled: true })); + source.enable(); + expect(source.disabled).toBe(false); + }); + + it('should push into sink', () => { + const push = jest.fn(); + EventSinkMock.mockImplementation(() => { + return { push } as unknown as EventSink; + }); + const source = new Source(createDefaultSourceOptions()); + source.push(new ConsoleMessage('warn', 'msg', [])); + expect(push).toBeCalledTimes(1); + expect(push).toBeCalledWith(new ConsoleMessage('warn', 'msg', [])); + }); + + it('should flush from sink', () => { + const flush = jest.fn().mockReturnValue([ + { + message: 'foo', + timestamp: 1, + }, + ] as InputLogEvent[]); + EventSinkMock.mockImplementation(() => { + return { flush } as unknown as EventSink; + }); + const source = new Source(createDefaultSourceOptions()); + expect(source.flush()).toEqual([ + { + message: 'foo', + timestamp: 1, + }, + ]); + expect(flush).toBeCalledTimes(1); + }); +}); diff --git a/tests/source/sourceCollection.test.ts b/tests/source/sourceCollection.test.ts new file mode 100644 index 0000000..b54542e --- /dev/null +++ b/tests/source/sourceCollection.test.ts @@ -0,0 +1,36 @@ +import { EventSink, Source, SourceCollection } from '../../src'; + +jest.mock('../../src/source/eventSink'); +const EventSinkMock = EventSink as jest.Mock; + +const defaults = { + muted: false, + disabled: false, + messageFormatter: () => 'dummy', + timestampProvider: () => 1, + eventSink: new EventSinkMock(), +}; + +const createSources = () => + new SourceCollection([ + new Source({ ...defaults, level: 'error' }), + new Source({ ...defaults, level: 'error' }), + new Source({ ...defaults, level: 'warn' }), + new Source({ ...defaults, level: 'warn' }), + new Source({ ...defaults, level: 'warn' }), + ]); + +describe('sourceCollection', () => { + it('should filter by level', () => { + const sources = createSources(); + expect(sources.filterByLevel('error').items).toHaveLength(2); + expect(sources.filterByLevel('warn').items).toHaveLength(3); + }); + + it('should find by level', () => { + const sources = createSources(); + expect(sources.findByLevel('error')).toBe(sources.items[0]); + expect(sources.findByLevel('warn')).toBe(sources.items[2]); + expect(sources.findByLevel('info')).toBeUndefined(); + }); +}); diff --git a/tests/source/sourceFactory.test.ts b/tests/source/sourceFactory.test.ts new file mode 100644 index 0000000..5de9634 --- /dev/null +++ b/tests/source/sourceFactory.test.ts @@ -0,0 +1,70 @@ +import { SourceFactory, Message, ConsoleMessage } from '../../src'; + +describe('sourceFactory', () => { + it('should create with global default values', async () => { + const factory = new SourceFactory(); + const source = factory.createSource('error'); + expect(source.muted).toBe(false); + expect(source.disabled).toBe(false); + expect( + await source.messageFormatter(new ConsoleMessage('error', 'Error!', [])) + ).toBe('{"type":"console","message":"Error!","level":"error","params":[]}'); + }); + + it('should create with factory default values', async () => { + const factoryOptions = { + muted: true, + disabled: true, + messageFormatter: async (m: Message) => `${m.type} error`, + }; + const factory = new SourceFactory(factoryOptions); + const source = factory.createSource('error'); + expect(source.muted).toBe(true); + expect(source.disabled).toBe(true); + expect( + await source.messageFormatter(new ConsoleMessage('error', 'Error!', [])) + ).toBe('console error'); + }); + + it('should create with artificial values(1)', async () => { + const sourceOptions = { + muted: true, + disabled: true, + messageFormatter: async (m: Message) => `${m.type} error`, + }; + const factory = new SourceFactory(); + const source = factory.createSource('error', sourceOptions); + expect(source.muted).toBe(true); + expect(source.disabled).toBe(true); + expect( + await source.messageFormatter(new ConsoleMessage('error', 'Error!', [])) + ).toBe('console error'); + }); + + it('should create with artificial values(2)', async () => { + const sourceOptions = { + level: 'error' as const, + muted: true, + disabled: true, + messageFormatter: async (m: Message) => `${m.type} error`, + }; + const factory = new SourceFactory(); + const source = factory.createSource(sourceOptions); + expect(source.muted).toBe(true); + expect(source.disabled).toBe(true); + expect( + await source.messageFormatter(new ConsoleMessage('error', 'Error!', [])) + ).toBe('console error'); + }); + + it('should create multiple channels', async () => { + const factory = new SourceFactory(); + const sources = factory.createSources(['error', 'warn'], { muted: true }); + expect(sources.items[0].level).toBe('error'); + expect(sources.items[0].muted).toBe(true); + expect(sources.items[0].disabled).toBe(false); + expect(sources.items[1].level).toBe('warn'); + expect(sources.items[1].muted).toBe(true); + expect(sources.items[1].disabled).toBe(false); + }); +}); diff --git a/tests/stub.ts b/tests/stub.ts index 2e60a40..695630b 100644 --- a/tests/stub.ts +++ b/tests/stub.ts @@ -13,14 +13,14 @@ import { } from '@aws-sdk/client-cloudwatch-logs'; export class DummyStorage implements StorageInterface { - public storage: { [key: string]: string } = {}; - public getItem(key: string): string | null { + storage: { [key: string]: string } = {}; + getItem(key: string): string | null { return this.storage[key] || null; } - public removeItem(key: string): void { + removeItem(key: string): void { delete this.storage[key]; } - public setItem(key: string, value: string): void { + setItem(key: string, value: string): void { this.storage[key] = value; } } @@ -31,35 +31,33 @@ export interface DummyConsoleMessage { } export class DummyConsole implements ConsoleInterface { - public messages: DummyConsoleMessage[] = []; - public debug(message?: unknown): void { + messages: DummyConsoleMessage[] = []; + debug(message?: unknown): void { this.messages.push({ message, level: 'debug' }); } - public info(message?: unknown): void { + info(message?: unknown): void { this.messages.push({ message, level: 'info' }); } - public log(message?: unknown): void { + log(message?: unknown): void { this.messages.push({ message, level: 'log' }); } - public error(message?: unknown): void { + error(message?: unknown): void { this.messages.push({ message, level: 'error' }); } - public warn(message?: unknown): void { + warn(message?: unknown): void { this.messages.push({ message, level: 'warn' }); } } export class DummyClient implements ClientInterface { - public sink: { [key: string]: any[] } = {}; - public sequenceToken: string | null = null; + sink: { [key: string]: any[] } = {}; + sequenceToken: string | null = null; - public async send( + async send( command: CreateLogStreamCommand ): Promise; - public async send( - command: PutLogEventsCommand - ): Promise; - public async send( + async send(command: PutLogEventsCommand): Promise; + async send( command: CreateLogStreamCommand | PutLogEventsCommand ): Promise { if (command instanceof CreateLogStreamCommand) { @@ -84,7 +82,7 @@ export class DummyClient implements ClientInterface { } export class DummyEventTarget implements EventTarget { - public listeners: { [key: string]: EventListener } = {}; + listeners: { [key: string]: EventListener } = {}; addEventListener(type: string, listener: EventListener): void { this.listeners[type] = listener; diff --git a/tests/util/error.test.ts b/tests/util/error.test.ts new file mode 100644 index 0000000..80224bf --- /dev/null +++ b/tests/util/error.test.ts @@ -0,0 +1,55 @@ +import { isValidAWSError, isValidError } from '../../src'; +import { DummyAWSError } from '../stub'; + +describe('isValidError', () => { + it('should pass basic error', () => { + expect(isValidError(new Error())).toBe(true); + }); + it('should pass AWSError', () => { + expect( + isValidError( + new DummyAWSError('message', 'DummyAWSError', 'sequenceToken') + ) + ).toBe(true); + }); + it('should block pure object', () => { + expect( + isValidError({ + name: 'Error', + message: 'Message', + }) + ).toBe(false); + }); + it('should block null', () => { + expect(isValidError(null)).toBe(false); + }); +}); + +describe('isValidAWSError', () => { + it('should pass basic error', () => { + expect(isValidAWSError(new Error())).toBe(true); + }); + it('should block AWSError with invalid expectedSequenceToken', () => { + expect( + isValidAWSError(new DummyAWSError('message', 'DummyAWSError', 123 as any)) + ).toBe(false); + }); + it('should pass AWSError', () => { + expect( + isValidAWSError( + new DummyAWSError('message', 'DummyAWSError', 'sequenceToken') + ) + ).toBe(true); + }); + it('should block pure object', () => { + expect( + isValidAWSError({ + name: 'Error', + message: 'Message', + }) + ).toBe(false); + }); + it('should block null', () => { + expect(isValidAWSError(null)).toBe(false); + }); +});