diff --git a/packages/amplify-ui-vue/tsconfig.json b/packages/amplify-ui-vue/tsconfig.json index 8dd3f209bd9..0db6e5d4edd 100755 --- a/packages/amplify-ui-vue/tsconfig.json +++ b/packages/amplify-ui-vue/tsconfig.json @@ -6,7 +6,7 @@ "emitDecoratorMetadata": true, "experimentalDecorators": true, "esModuleInterop": true, - "lib": ["dom", "es2015"], + "lib": ["dom", "es2015", "es2018.asynciterable", "es2018.asyncgenerator"], "module": "es2015", "moduleResolution": "node", "noImplicitAny": true, diff --git a/packages/aws-amplify/__tests__/exports-test.ts b/packages/aws-amplify/__tests__/exports-test.ts index aa0e280ad63..cfcdb9ad066 100644 --- a/packages/aws-amplify/__tests__/exports-test.ts +++ b/packages/aws-amplify/__tests__/exports-test.ts @@ -91,6 +91,7 @@ describe('aws-amplify', () => { "Signer", "I18n", "ServiceWorker", + "AWSCloudWatchProvider", "withSSRContext", "default", ] diff --git a/packages/aws-amplify/src/index.ts b/packages/aws-amplify/src/index.ts index 60f10fedda3..ad22495e231 100644 --- a/packages/aws-amplify/src/index.ts +++ b/packages/aws-amplify/src/index.ts @@ -54,6 +54,7 @@ export { Signer, I18n, ServiceWorker, + AWSCloudWatchProvider, } from '@aws-amplify/core'; export { withSSRContext } from './withSSRContext'; diff --git a/packages/core/__tests__/ConsoleLogger-test.ts b/packages/core/__tests__/ConsoleLogger-test.ts new file mode 100644 index 00000000000..4192c594ae6 --- /dev/null +++ b/packages/core/__tests__/ConsoleLogger-test.ts @@ -0,0 +1,50 @@ +import { + AWSCloudWatchProvider, + AWS_CLOUDWATCH_PROVIDER_NAME, + Logger, +} from '../src'; + +describe('ConsoleLogger', () => { + describe('pluggables', () => { + it('should store pluggables correctly when addPluggable is called', () => { + const provider = new AWSCloudWatchProvider(); + const logger = new Logger('name'); + logger.addPluggable(provider); + const pluggables = logger.listPluggables(); + + expect(pluggables).toHaveLength(1); + expect(pluggables[0].getProviderName()).toEqual( + AWS_CLOUDWATCH_PROVIDER_NAME + ); + }); + + it('should do nothing when no plugin is provided to addPluggable', () => { + const logger = new Logger('name'); + logger.addPluggable(); + const pluggables = logger.listPluggables(); + + expect(pluggables).toHaveLength(0); + }); + + it('should do nothing when a non-logging category plugin is provided to addPluggable', () => { + const provider = { + getCategoryName: function() { + return 'non-logging'; + }, + getProviderName: function() { + return 'lol'; + }, + configure: function() { + return {}; + }, + pushLogs: null, + }; + + const logger = new Logger('name'); + logger.addPluggable(provider); + const pluggables = logger.listPluggables(); + + expect(pluggables).toHaveLength(0); + }); + }); +}); diff --git a/packages/core/__tests__/Providers/AWSCloudWatchProvider-test.ts b/packages/core/__tests__/Providers/AWSCloudWatchProvider-test.ts new file mode 100644 index 00000000000..877cd873b88 --- /dev/null +++ b/packages/core/__tests__/Providers/AWSCloudWatchProvider-test.ts @@ -0,0 +1,248 @@ +import { AWSCloudWatchProvider } from '../../src/Providers/AWSCloudWatchProvider'; +import { AWSCloudWatchProviderOptions } from '../../src/types'; +import { + AWS_CLOUDWATCH_CATEGORY, + AWS_CLOUDWATCH_PROVIDER_NAME, +} from '../../src/Util/Constants'; +import { Credentials } from '../..'; +import { CloudWatchLogsClient } from '@aws-sdk/client-cloudwatch-logs'; + +const credentials = { + accessKeyId: 'accessKeyId', + sessionToken: 'sessionToken', + secretAccessKey: 'secretAccessKey', + identityId: 'identityId', + authenticated: true, +}; + +const testConfig: AWSCloudWatchProviderOptions = { + logGroupName: 'logGroup', + logStreamName: 'logStream', + region: 'us-west-2', +}; + +describe('AWSCloudWatchProvider', () => { + it('should initiate a timer when the provider is created', () => { + const timer_spy = jest.spyOn( + AWSCloudWatchProvider.prototype, + '_initiateLogPushInterval' + ); + const provider = new AWSCloudWatchProvider(); + expect(timer_spy).toBeCalled(); + }); + + describe('getCategoryName()', () => { + it('should return the AWS_CLOUDWATCH_CATEGORY', () => { + const provider = new AWSCloudWatchProvider(); + expect(provider.getCategoryName()).toBe(AWS_CLOUDWATCH_CATEGORY); + }); + }); + + describe('getProviderName()', () => { + it('should return the AWS_CLOUDWATCH_PROVIDER_NAME', () => { + const provider = new AWSCloudWatchProvider(); + expect(provider.getProviderName()).toBe(AWS_CLOUDWATCH_PROVIDER_NAME); + }); + }); + + describe('configure()', () => { + it('should return a config with logGroupName, logStreamName, and region when given a config input', () => { + const provider = new AWSCloudWatchProvider(); + const config = provider.configure(testConfig); + + expect(config).toStrictEqual(testConfig); + expect(config).toHaveProperty('logGroupName'); + expect(config).toHaveProperty('logStreamName'); + expect(config).toHaveProperty('region'); + }); + + it('should return an empty object when given no config input', () => { + const provider = new AWSCloudWatchProvider(); + expect(provider.configure()).toStrictEqual({}); + }); + }); + + describe('credentials test', () => { + it('without credentials', async () => { + const provider = new AWSCloudWatchProvider(); + provider.configure(testConfig); + const spyon = jest.spyOn(Credentials, 'get').mockImplementation(() => { + return Promise.reject('err'); + }); + + const action = async () => { + await provider.createLogGroup({ + logGroupName: testConfig.logGroupName, + }); + }; + + expect(action()).rejects.toThrowError(); + spyon.mockRestore(); + }); + }); + + describe('CloudWatch api tests', () => { + beforeEach(() => { + jest.spyOn(Credentials, 'get').mockImplementation(() => { + return Promise.resolve(credentials); + }); + }); + + describe('createLogGroup test', () => { + const createLogGroupParams = { logGroupName: 'group-name' }; + + it('should send a valid request to the client without error', async () => { + const provider = new AWSCloudWatchProvider(testConfig); + const clientSpy = jest + .spyOn(CloudWatchLogsClient.prototype, 'send') + .mockImplementationOnce(async params => { + return 'data'; + }); + + await provider.createLogGroup(createLogGroupParams); + expect(clientSpy.mock.calls[0][0].input).toEqual(createLogGroupParams); + + clientSpy.mockRestore(); + }); + }); + + describe('createLogStream test', () => { + const params = { + logGroupName: 'group-name', + logStreamName: 'stream-name', + }; + + it('should send a valid request to the client without error', async () => { + const provider = new AWSCloudWatchProvider(testConfig); + const clientSpy = jest + .spyOn(CloudWatchLogsClient.prototype, 'send') + .mockImplementationOnce(async params => { + return 'data'; + }); + + await provider.createLogStream(params); + expect(clientSpy.mock.calls[0][0].input).toEqual(params); + + clientSpy.mockRestore(); + }); + }); + + describe('getLogGroups test', () => { + const params = { + logGroupNamePrefix: 'group-name', + }; + + it('should send a valid request to the client without error', async () => { + const provider = new AWSCloudWatchProvider(testConfig); + const clientSpy = jest + .spyOn(CloudWatchLogsClient.prototype, 'send') + .mockImplementationOnce(async params => { + return 'data'; + }); + + await provider.getLogGroups(params); + expect(clientSpy.mock.calls[0][0].input).toEqual(params); + + clientSpy.mockRestore(); + }); + }); + + describe('getLogStreams test', () => { + const params = { + logGroupName: 'group-name', + logStreamNamePrefix: 'stream-name', + }; + + it('should send a valid request to the client without error', async () => { + const provider = new AWSCloudWatchProvider(testConfig); + const clientSpy = jest + .spyOn(CloudWatchLogsClient.prototype, 'send') + .mockImplementationOnce(async params => { + return 'data'; + }); + + await provider.getLogStreams(params); + expect(clientSpy.mock.calls[0][0].input).toEqual(params); + + clientSpy.mockRestore(); + }); + }); + + describe('pushLogs test', () => { + it('should add the provided logs to the log queue', () => { + const provider = new AWSCloudWatchProvider(testConfig); + provider.pushLogs([{ message: 'hello', timestamp: 1111 }]); + + let logQueue = provider.getLogQueue(); + + expect(logQueue).toHaveLength(1); + + provider.pushLogs([ + { + message: 'goodbye', + timestamp: 1112, + }, + { + message: 'ohayou', + timestamp: 1113, + }, + { + message: 'konbanwa', + timestamp: 1114, + }, + ]); + + logQueue = provider.getLogQueue(); + + expect(logQueue).toHaveLength(4); + }); + }); + + describe('_safeUploadLogEvents test', () => { + it('should send a valid request to the client without error', async () => { + const params = [{ message: 'hello', timestamp: 1111 }]; + const provider = new AWSCloudWatchProvider(testConfig); + const clientSpy = jest + .spyOn(CloudWatchLogsClient.prototype, 'send') + .mockImplementation(async params => { + return 'data'; + }); + + provider['_currentLogBatch'] = params; + await provider['_safeUploadLogEvents'](); + + // DescribeLogGroups command + expect(clientSpy.mock.calls[0][0].input).toEqual({ + logGroupNamePrefix: testConfig.logGroupName, + }); + + // CreateLogGroup command + expect(clientSpy.mock.calls[1][0].input).toEqual({ + logGroupName: testConfig.logGroupName, + }); + + // DescribeLogStreams command + expect(clientSpy.mock.calls[2][0].input).toEqual({ + logGroupName: testConfig.logGroupName, + logStreamNamePrefix: testConfig.logStreamName, + }); + + // CreateLogStream command + expect(clientSpy.mock.calls[3][0].input).toEqual({ + logGroupName: testConfig.logGroupName, + logStreamName: testConfig.logStreamName, + }); + + // PutLogEvents command + expect(clientSpy.mock.calls[4][0].input).toEqual({ + logGroupName: testConfig.logGroupName, + logStreamName: testConfig.logStreamName, + logEvents: params, + sequenceToken: '', + }); + + clientSpy.mockRestore(); + }); + }); + }); +}); diff --git a/packages/core/__tests__/parseMobileHubConfig-test.ts b/packages/core/__tests__/parseMobileHubConfig-test.ts index 2485c130df6..02a9190172d 100644 --- a/packages/core/__tests__/parseMobileHubConfig-test.ts +++ b/packages/core/__tests__/parseMobileHubConfig-test.ts @@ -36,6 +36,7 @@ describe('Parser', () => { aws_user_pools_id: 'b', aws_user_pools_web_client_id: '', }, + Logging: {}, }); }); }); diff --git a/packages/core/package.json b/packages/core/package.json index c57158dd37b..15b8bcfcc72 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -59,6 +59,7 @@ "@aws-sdk/client-cognito-identity": "3.6.1", "@aws-sdk/credential-provider-cognito-identity": "3.6.1", "@aws-sdk/types": "3.6.1", + "@aws-sdk/client-cloudwatch-logs": "3.6.1", "@aws-sdk/util-hex-encoding": "3.6.1", "universal-cookie": "^4.0.4", "zen-observable-ts": "0.8.19" diff --git a/packages/core/src/Logger/ConsoleLogger.ts b/packages/core/src/Logger/ConsoleLogger.ts index 3d5dab81a7b..37bdbcd5370 100644 --- a/packages/core/src/Logger/ConsoleLogger.ts +++ b/packages/core/src/Logger/ConsoleLogger.ts @@ -11,6 +11,9 @@ * and limitations under the License. */ +import { InputLogEvent } from '@aws-sdk/client-cloudwatch-logs'; +import { LoggingProvider } from '../types'; +import { AWS_CLOUDWATCH_CATEGORY } from '../Util/Constants'; import { Logger } from './logger-interface'; const LOG_LEVELS = { @@ -21,21 +24,32 @@ const LOG_LEVELS = { ERROR: 5, }; +export enum LOG_TYPE { + DEBUG = 'DEBUG', + ERROR = 'ERROR', + INFO = 'INFO', + WARN = 'WARN', + VERBOSE = 'VERBOSE', +} + /** * Write logs * @class Logger */ export class ConsoleLogger implements Logger { name: string; - level: string; + level: LOG_TYPE | string; + private _pluggables: LoggingProvider[]; + private _config: object; /** * @constructor * @param {string} name - Name of the logger */ - constructor(name, level = 'WARN') { + constructor(name: string, level: LOG_TYPE | string = LOG_TYPE.WARN) { this.name = name; this.level = level; + this._pluggables = []; } static LOG_LEVEL = null; @@ -55,14 +69,22 @@ export class ConsoleLogger implements Logger { ); } + configure(config?: object) { + if (!config) return this._config; + + this._config = config; + + return this._config; + } + /** * Write log * @method * @memeberof Logger - * @param {string} type - log type, default INFO + * @param {LOG_TYPE|string} type - log type, default INFO * @param {string|object} msg - Logging message or object */ - _log(type: string, ...msg) { + _log(type: LOG_TYPE | string, ...msg) { let logger_level_name = this.level; if (ConsoleLogger.LOG_LEVEL) { logger_level_name = ConsoleLogger.LOG_LEVEL; @@ -78,28 +100,38 @@ export class ConsoleLogger implements Logger { } let log = console.log.bind(console); - if (type === 'ERROR' && console.error) { + if (type === LOG_TYPE.ERROR && console.error) { log = console.error.bind(console); } - if (type === 'WARN' && console.warn) { + if (type === LOG_TYPE.WARN && console.warn) { log = console.warn.bind(console); } const prefix = `[${type}] ${this._ts()} ${this.name}`; + let message = ''; if (msg.length === 1 && typeof msg[0] === 'string') { - log(`${prefix} - ${msg[0]}`); + message = `${prefix} - ${msg[0]}`; + log(message); } else if (msg.length === 1) { + message = `${prefix} ${msg[0]}`; log(prefix, msg[0]); } else if (typeof msg[0] === 'string') { let obj = msg.slice(1); if (obj.length === 1) { obj = obj[0]; } + message = `${prefix} - ${msg[0]} ${obj}`; log(`${prefix} - ${msg[0]}`, obj); } else { + message = `${prefix} ${msg}`; log(prefix, msg); } + + for (const plugin of this._pluggables) { + const logEvent: InputLogEvent = { message, timestamp: Date.now() }; + plugin.pushLogs([logEvent]); + } } /** @@ -109,7 +141,7 @@ export class ConsoleLogger implements Logger { * @param {string|object} msg - Logging message or object */ log(...msg) { - this._log('INFO', ...msg); + this._log(LOG_TYPE.INFO, ...msg); } /** @@ -119,7 +151,7 @@ export class ConsoleLogger implements Logger { * @param {string|object} msg - Logging message or object */ info(...msg) { - this._log('INFO', ...msg); + this._log(LOG_TYPE.INFO, ...msg); } /** @@ -129,7 +161,7 @@ export class ConsoleLogger implements Logger { * @param {string|object} msg - Logging message or object */ warn(...msg) { - this._log('WARN', ...msg); + this._log(LOG_TYPE.WARN, ...msg); } /** @@ -139,7 +171,7 @@ export class ConsoleLogger implements Logger { * @param {string|object} msg - Logging message or object */ error(...msg) { - this._log('ERROR', ...msg); + this._log(LOG_TYPE.ERROR, ...msg); } /** @@ -149,7 +181,7 @@ export class ConsoleLogger implements Logger { * @param {string|object} msg - Logging message or object */ debug(...msg) { - this._log('DEBUG', ...msg); + this._log(LOG_TYPE.DEBUG, ...msg); } /** @@ -159,6 +191,17 @@ export class ConsoleLogger implements Logger { * @param {string|object} msg - Logging message or object */ verbose(...msg) { - this._log('VERBOSE', ...msg); + this._log(LOG_TYPE.VERBOSE, ...msg); + } + + addPluggable(pluggable: LoggingProvider) { + if (pluggable && pluggable.getCategoryName() === AWS_CLOUDWATCH_CATEGORY) { + this._pluggables.push(pluggable); + pluggable.configure(this._config); + } + } + + listPluggables() { + return this._pluggables; } } diff --git a/packages/core/src/Logger/logger-interface.ts b/packages/core/src/Logger/logger-interface.ts index d99757d57d6..340e39b0f23 100644 --- a/packages/core/src/Logger/logger-interface.ts +++ b/packages/core/src/Logger/logger-interface.ts @@ -11,11 +11,14 @@ * and limitations under the License. */ +import { LoggingProvider } from '../types'; + export interface Logger { debug(msg: string): void; info(msg: string): void; warn(msg: string): void; error(msg: string): void; + addPluggable(pluggable: LoggingProvider): void; } /** diff --git a/packages/core/src/Parser.ts b/packages/core/src/Parser.ts index 725e5993335..687bb0ee7de 100644 --- a/packages/core/src/Parser.ts +++ b/packages/core/src/Parser.ts @@ -44,6 +44,15 @@ export const parseMobileHubConfig = (config): AmplifyConfig => { } else { storageConfig = config ? config.Storage || config : {}; } + + // Logging + if (config['Logging']) { + amplifyConfig.Logging = { + ...config['Logging'], + region: config['aws_project_region'], + }; + } + amplifyConfig.Analytics = Object.assign( {}, amplifyConfig.Analytics, @@ -51,6 +60,11 @@ export const parseMobileHubConfig = (config): AmplifyConfig => { ); amplifyConfig.Auth = Object.assign({}, amplifyConfig.Auth, config.Auth); amplifyConfig.Storage = Object.assign({}, storageConfig); + amplifyConfig.Logging = Object.assign( + {}, + amplifyConfig.Logging, + config.Logging + ); logger.debug('parse config', config, 'to amplifyconfig', amplifyConfig); return amplifyConfig; }; diff --git a/packages/core/src/Providers/AWSCloudWatchProvider.ts b/packages/core/src/Providers/AWSCloudWatchProvider.ts new file mode 100644 index 00000000000..431c67b51e2 --- /dev/null +++ b/packages/core/src/Providers/AWSCloudWatchProvider.ts @@ -0,0 +1,534 @@ +/* + * Copyright 2017-2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance with + * the License. A copy of the License is located at + * + * http://aws.amazon.com/apache2.0/ + * + * or in the "license" file accompanying this file. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR + * CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions + * and limitations under the License. + */ + +import { + CloudWatchLogsClient, + CreateLogGroupCommand, + CreateLogGroupCommandInput, + CreateLogGroupCommandOutput, + CreateLogStreamCommand, + CreateLogStreamCommandInput, + CreateLogStreamCommandOutput, + DescribeLogGroupsCommand, + DescribeLogGroupsCommandInput, + DescribeLogGroupsCommandOutput, + DescribeLogStreamsCommand, + DescribeLogStreamsCommandInput, + DescribeLogStreamsCommandOutput, + GetLogEventsCommand, + GetLogEventsCommandInput, + GetLogEventsCommandOutput, + InputLogEvent, + LogGroup, + LogStream, + PutLogEventsCommand, + PutLogEventsCommandInput, + PutLogEventsCommandOutput, +} from '@aws-sdk/client-cloudwatch-logs'; +import { + AWSCloudWatchProviderOptions, + CloudWatchDataTracker, + LoggingProvider, +} from '../types/types'; +import { Credentials } from '../..'; +import { ConsoleLogger as Logger } from '../Logger'; +import { getAmplifyUserAgent } from '../Platform'; +import { parseMobileHubConfig } from '../Parser'; +import { + AWS_CLOUDWATCH_BASE_BUFFER_SIZE, + AWS_CLOUDWATCH_CATEGORY, + AWS_CLOUDWATCH_MAX_BATCH_EVENT_SIZE, + AWS_CLOUDWATCH_MAX_EVENT_SIZE, + AWS_CLOUDWATCH_PROVIDER_NAME, + NO_CREDS_ERROR_STRING, + RETRY_ERROR_CODES, +} from '../Util/Constants'; + +const logger = new Logger('AWSCloudWatch'); + +class AWSCloudWatchProvider implements LoggingProvider { + static readonly PROVIDER_NAME = AWS_CLOUDWATCH_PROVIDER_NAME; + static readonly CATEGORY = AWS_CLOUDWATCH_CATEGORY; + + private _config: AWSCloudWatchProviderOptions; + private _dataTracker: CloudWatchDataTracker; + private _currentLogBatch: InputLogEvent[]; + private _timer; + private _nextSequenceToken: string | undefined; + + constructor(config?: AWSCloudWatchProviderOptions) { + this.configure(config); + this._dataTracker = { + eventUploadInProgress: false, + logEvents: [], + }; + this._currentLogBatch = []; + this._initiateLogPushInterval(); + } + + public getProviderName(): string { + return AWSCloudWatchProvider.PROVIDER_NAME; + } + + public getCategoryName(): string { + return AWSCloudWatchProvider.CATEGORY; + } + + public getLogQueue(): InputLogEvent[] { + return this._dataTracker.logEvents; + } + + public configure( + config?: AWSCloudWatchProviderOptions + ): AWSCloudWatchProviderOptions { + if (!config) return this._config || {}; + + const conf = Object.assign( + {}, + this._config, + parseMobileHubConfig(config).Logging, + config + ); + this._config = conf; + + return this._config; + } + + public async createLogGroup( + params: CreateLogGroupCommandInput + ): Promise { + logger.debug( + 'creating new log group in CloudWatch - ', + params.logGroupName + ); + const cmd = new CreateLogGroupCommand(params); + + try { + const credentialsOK = await this._ensureCredentials(); + if (!credentialsOK) { + logger.error(NO_CREDS_ERROR_STRING); + throw Error; + } + + const client = this._initCloudWatchLogs(); + const output = await client.send(cmd); + return output; + } catch (error) { + logger.error(`error creating log group - ${error}`); + throw error; + } + } + + public async getLogGroups( + params: DescribeLogGroupsCommandInput + ): Promise { + logger.debug('getting list of log groups'); + + const cmd = new DescribeLogGroupsCommand(params); + + try { + const credentialsOK = await this._ensureCredentials(); + if (!credentialsOK) { + logger.error(NO_CREDS_ERROR_STRING); + throw Error; + } + + const client = this._initCloudWatchLogs(); + const output = await client.send(cmd); + return output; + } catch (error) { + logger.error(`error getting log group - ${error}`); + throw error; + } + } + + public async createLogStream( + params: CreateLogStreamCommandInput + ): Promise { + logger.debug( + 'creating new log stream in CloudWatch - ', + params.logStreamName + ); + const cmd = new CreateLogStreamCommand(params); + + try { + const credentialsOK = await this._ensureCredentials(); + if (!credentialsOK) { + logger.error(NO_CREDS_ERROR_STRING); + throw Error; + } + + const client = this._initCloudWatchLogs(); + const output = await client.send(cmd); + return output; + } catch (error) { + logger.error(`error creating log stream - ${error}`); + throw error; + } + } + + public async getLogStreams( + params: DescribeLogStreamsCommandInput + ): Promise { + logger.debug('getting list of log streams'); + const cmd = new DescribeLogStreamsCommand(params); + + try { + const credentialsOK = await this._ensureCredentials(); + if (!credentialsOK) { + logger.error(NO_CREDS_ERROR_STRING); + throw Error; + } + + const client = this._initCloudWatchLogs(); + const output = await client.send(cmd); + return output; + } catch (error) { + logger.error(`error getting log stream - ${error}`); + throw error; + } + } + + public async getLogEvents( + params: GetLogEventsCommandInput + ): Promise { + logger.debug('getting log events from stream - ', params.logStreamName); + const cmd = new GetLogEventsCommand(params); + + try { + const credentialsOK = await this._ensureCredentials(); + if (!credentialsOK) { + logger.error(NO_CREDS_ERROR_STRING); + throw Error; + } + + const client = this._initCloudWatchLogs(); + const output = await client.send(cmd); + return output; + } catch (error) { + logger.error(`error getting log events - ${error}`); + throw error; + } + } + + public pushLogs(logs: InputLogEvent[]): void { + logger.debug('pushing log events to Cloudwatch...'); + this._dataTracker.logEvents = [...this._dataTracker.logEvents, ...logs]; + } + + private async _validateLogGroupExistsAndCreate( + logGroupName: string + ): Promise { + if (this._dataTracker.verifiedLogGroup) { + return this._dataTracker.verifiedLogGroup; + } + + try { + const credentialsOK = await this._ensureCredentials(); + if (!credentialsOK) { + logger.error(NO_CREDS_ERROR_STRING); + throw Error; + } + + const currGroups = await this.getLogGroups({ + logGroupNamePrefix: logGroupName, + }); + + if (!(typeof currGroups === 'string') && currGroups.logGroups) { + const foundGroups = currGroups.logGroups.filter( + group => group.logGroupName === logGroupName + ); + if (foundGroups.length > 0) { + this._dataTracker.verifiedLogGroup = foundGroups[0]; + + return foundGroups[0]; + } + } + + /** + * If we get to this point, it means that the specified log group does not exist + * and we should create it. + */ + await this.createLogGroup({ logGroupName }); + + return null; + } catch (err) { + const errString = `failure during log group search: ${err}`; + logger.error(errString); + throw err; + } + } + + private async _validateLogStreamExists( + logGroupName: string, + logStreamName: string + ): Promise { + try { + const credentialsOK = await this._ensureCredentials(); + if (!credentialsOK) { + logger.error(NO_CREDS_ERROR_STRING); + throw Error; + } + + const currStreams = await this.getLogStreams({ + logGroupName, + logStreamNamePrefix: logStreamName, + }); + + if (currStreams.logStreams) { + const foundStreams = currStreams.logStreams.filter( + stream => stream.logStreamName === logStreamName + ); + if (foundStreams.length > 0) { + this._nextSequenceToken = foundStreams[0].uploadSequenceToken; + + return foundStreams[0]; + } + } + + /** + * If we get to this point, it means that the specified stream does not + * exist, and we should create it now. + */ + await this.createLogStream({ + logGroupName, + logStreamName, + }); + + return null; + } catch (err) { + const errString = `failure during log stream search: ${err}`; + logger.error(errString); + throw err; + } + } + + private async _sendLogEvents( + params: PutLogEventsCommandInput + ): Promise { + try { + const credentialsOK = await this._ensureCredentials(); + if (!credentialsOK) { + logger.error(NO_CREDS_ERROR_STRING); + throw Error; + } + + logger.debug('sending log events to stream - ', params.logStreamName); + const cmd = new PutLogEventsCommand(params); + const client = this._initCloudWatchLogs(); + const output = await client.send(cmd); + + return output; + } catch (err) { + const errString = `failure during log push: ${err}`; + logger.error(errString); + } + } + + private _initCloudWatchLogs() { + return new CloudWatchLogsClient({ + region: this._config.region, + credentials: this._config.credentials, + customUserAgent: getAmplifyUserAgent(), + endpoint: this._config.endpoint, + }); + } + + private async _ensureCredentials() { + return await Credentials.get() + .then(credentials => { + if (!credentials) return false; + const cred = Credentials.shear(credentials); + logger.debug('set credentials for logging', cred); + this._config.credentials = cred; + + return true; + }) + .catch(error => { + logger.warn('ensure credentials error', error); + return false; + }); + } + + private async _getNextSequenceToken(): Promise { + if (this._nextSequenceToken && this._nextSequenceToken.length > 0) { + return this._nextSequenceToken; + } + + /** + * A sequence token will not exist if any of the following are true: + * ...the log group does not exist + * ...the log stream does not exist + * ...the log stream does exist but has no logs written to it yet + */ + try { + await this._validateLogGroupExistsAndCreate(this._config.logGroupName); + + const logStream = await this._validateLogStreamExists( + this._config.logGroupName, + this._config.logStreamName + ); + if (!logStream) { + this._nextSequenceToken = ''; + + return ''; + } + + this._nextSequenceToken = logStream.uploadSequenceToken || ''; + + return this._nextSequenceToken; + } catch (err) { + logger.error(`failure while getting next sequence token: ${err}`); + throw err; + } + } + + private async _safeUploadLogEvents(): Promise { + try { + /** + * CloudWatch has restrictions on the size of the log events that get sent up. + * We need to track both the size of each event and the total size of the batch + * of logs. + * + * We also need to ensure that the logs in the batch are sorted in chronological order. + * https://docs.aws.amazon.com/AmazonCloudWatchLogs/latest/APIReference/API_PutLogEvents.html + */ + const seqToken = await this._getNextSequenceToken(); + const logBatch = + this._currentLogBatch.length === 0 + ? this._getBufferedBatchOfLogs() + : this._currentLogBatch; + + const putLogsPayload: PutLogEventsCommandInput = { + logGroupName: this._config.logGroupName, + logStreamName: this._config.logStreamName, + logEvents: logBatch, + sequenceToken: seqToken, + }; + + this._dataTracker.eventUploadInProgress = true; + const sendLogEventsResponse = await this._sendLogEvents(putLogsPayload); + + this._nextSequenceToken = sendLogEventsResponse.nextSequenceToken; + this._dataTracker.eventUploadInProgress = false; + this._currentLogBatch = []; + + return sendLogEventsResponse; + } catch (err) { + logger.error(`error during _safeUploadLogEvents: ${err}`); + + if (RETRY_ERROR_CODES.includes(err.name)) { + this._getNewSequenceTokenAndSubmit({ + logEvents: this._currentLogBatch, + logGroupName: this._config.logGroupName, + logStreamName: this._config.logStreamName, + }); + } else { + this._dataTracker.eventUploadInProgress = false; + throw err; + } + } + } + + private _getBufferedBatchOfLogs(): InputLogEvent[] { + /** + * CloudWatch has restrictions on the size of the log events that get sent up. + * We need to track both the size of each event and the total size of the batch + * of logs. + * + * We also need to ensure that the logs in the batch are sorted in chronological order. + * https://docs.aws.amazon.com/AmazonCloudWatchLogs/latest/APIReference/API_PutLogEvents.html + */ + let currentEventIdx = 0; + let totalByteSize = 0; + + while (currentEventIdx < this._dataTracker.logEvents.length) { + const currentEvent = this._dataTracker.logEvents[currentEventIdx]; + const eventSize = currentEvent + ? new TextEncoder().encode(currentEvent.message).length + + AWS_CLOUDWATCH_BASE_BUFFER_SIZE + : 0; + if (eventSize > AWS_CLOUDWATCH_MAX_EVENT_SIZE) { + const errString = `Log entry exceeds maximum size for CloudWatch logs. Log size: ${eventSize}. Truncating log message.`; + logger.warn(errString); + + currentEvent.message = currentEvent.message.substring(0, eventSize); + } + + if (totalByteSize + eventSize > AWS_CLOUDWATCH_MAX_BATCH_EVENT_SIZE) + break; + totalByteSize += eventSize; + currentEventIdx++; + } + + this._currentLogBatch = this._dataTracker.logEvents.splice( + 0, + currentEventIdx + ); + + return this._currentLogBatch; + } + + private async _getNewSequenceTokenAndSubmit( + payload: PutLogEventsCommandInput + ): Promise { + try { + this._nextSequenceToken = ''; + this._dataTracker.eventUploadInProgress = true; + + const seqToken = await this._getNextSequenceToken(); + payload.sequenceToken = seqToken; + const sendLogEventsRepsonse = await this._sendLogEvents(payload); + + this._dataTracker.eventUploadInProgress = false; + this._currentLogBatch = []; + + return sendLogEventsRepsonse; + } catch (err) { + logger.error( + `error when retrying log submission with new sequence token: ${err}` + ); + this._dataTracker.eventUploadInProgress = false; + + throw err; + } + } + + private _initiateLogPushInterval(): void { + if (this._timer) { + clearInterval(this._timer); + } + + this._timer = setInterval(async () => { + try { + if (this._getDocUploadPermissibility()) { + await this._safeUploadLogEvents(); + } + } catch (err) { + logger.error( + `error when calling _safeUploadLogEvents in the timer interval - ${err}` + ); + } + }, 2000); + } + + private _getDocUploadPermissibility(): boolean { + return ( + (this._dataTracker.logEvents.length !== 0 || + this._currentLogBatch.length !== 0) && + !this._dataTracker.eventUploadInProgress + ); + } +} + +export { AWSCloudWatchProvider }; diff --git a/packages/core/src/Providers/index.ts b/packages/core/src/Providers/index.ts new file mode 100644 index 00000000000..8bf613736e4 --- /dev/null +++ b/packages/core/src/Providers/index.ts @@ -0,0 +1 @@ +export * from './AWSCloudWatchProvider'; diff --git a/packages/core/src/Util/Constants.ts b/packages/core/src/Util/Constants.ts new file mode 100644 index 00000000000..2d4f196b48d --- /dev/null +++ b/packages/core/src/Util/Constants.ts @@ -0,0 +1,34 @@ +/* + * Copyright 2017-2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance with + * the License. A copy of the License is located at + * + * http://aws.amazon.com/apache2.0/ + * + * or in the "license" file accompanying this file. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR + * CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions + * and limitations under the License. + */ + +// Logging constants +const AWS_CLOUDWATCH_BASE_BUFFER_SIZE = 26; +const AWS_CLOUDWATCH_MAX_BATCH_EVENT_SIZE = 1048576; +const AWS_CLOUDWATCH_MAX_EVENT_SIZE = 256000; +const AWS_CLOUDWATCH_CATEGORY = 'Logging'; +const AWS_CLOUDWATCH_PROVIDER_NAME = 'AWSCloudWatch'; +const NO_CREDS_ERROR_STRING = 'No credentials'; +const RETRY_ERROR_CODES = [ + 'ResourceNotFoundException', + 'InvalidSequenceTokenException', +]; + +export { + AWS_CLOUDWATCH_BASE_BUFFER_SIZE, + AWS_CLOUDWATCH_CATEGORY, + AWS_CLOUDWATCH_MAX_BATCH_EVENT_SIZE, + AWS_CLOUDWATCH_MAX_EVENT_SIZE, + AWS_CLOUDWATCH_PROVIDER_NAME, + NO_CREDS_ERROR_STRING, + RETRY_ERROR_CODES, +}; diff --git a/packages/core/src/Util/index.ts b/packages/core/src/Util/index.ts index eb130b3dfb4..6327097dbb1 100644 --- a/packages/core/src/Util/index.ts +++ b/packages/core/src/Util/index.ts @@ -3,3 +3,4 @@ export { default as Mutex } from './Mutex'; export { default as Reachability } from './Reachability'; export * from './DateUtils'; export * from './StringUtils'; +export * from './Constants'; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index aea8ffee5f4..d1e9505730f 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -23,6 +23,7 @@ export { I18n } from './I18n'; export * from './JS'; export { Signer } from './Signer'; export * from './Parser'; +export * from './Providers'; export { FacebookOAuth, GoogleOAuth } from './OAuthHelper'; export * from './RNComponents'; export { Credentials, CredentialsClass } from './Credentials'; diff --git a/packages/core/src/types/types.ts b/packages/core/src/types/types.ts index c571a7024a7..b44ced82529 100644 --- a/packages/core/src/types/types.ts +++ b/packages/core/src/types/types.ts @@ -1,7 +1,11 @@ +import { InputLogEvent, LogGroup } from '@aws-sdk/client-cloudwatch-logs'; +import { Credentials } from '@aws-sdk/types'; + export interface AmplifyConfig { Analytics?: object; Auth?: object; API?: object; + Logging?: object; Storage?: object; Cache?: object; ssr?: boolean; @@ -27,3 +31,31 @@ export type DelayFunction = ( args?: any[], error?: Error ) => number | false; + +export interface LoggingProvider { + // return the name of you provider + getProviderName(): string; + + // return the name of you category + getCategoryName(): string; + + // configure the plugin + configure(config?: object): object; + + // take logs and push to provider + pushLogs(logs: InputLogEvent[]): void; +} + +export interface AWSCloudWatchProviderOptions { + logGroupName?: string; + logStreamName?: string; + region?: string; + credentials?: Credentials; + endpoint?: string; +} + +export interface CloudWatchDataTracker { + eventUploadInProgress: boolean; + logEvents: InputLogEvent[]; + verifiedLogGroup?: LogGroup; +} diff --git a/packages/pushnotification/tsconfig.json b/packages/pushnotification/tsconfig.json index b13507f1867..dfdc07cf1d8 100755 --- a/packages/pushnotification/tsconfig.json +++ b/packages/pushnotification/tsconfig.json @@ -4,7 +4,15 @@ "outDir": "./lib/", "target": "es5", "noImplicitAny": false, - "lib": ["es5", "es2015", "dom", "esnext.asynciterable", "es2017.object"], + "lib": [ + "es5", + "es2015", + "dom", + "esnext.asynciterable", + "es2017.object", + "es2018.asynciterable", + "es2018.asyncgenerator" + ], "sourceMap": true, "module": "commonjs", "moduleResolution": "node",