diff --git a/docs/README.md b/docs/README.md index b92131b9a..4fd1636cb 100644 --- a/docs/README.md +++ b/docs/README.md @@ -53,6 +53,7 @@ The Serverless Framework allows you to deploy auto-scaling, pay-per-execution, e
  • Deploy
  • Invoke
  • Logs
  • +
  • Metrics
  • Info
  • Rollback
  • Remove
  • diff --git a/docs/providers/aws/cli-reference/info.md b/docs/providers/aws/cli-reference/info.md index f066a2ac8..22eeac745 100644 --- a/docs/providers/aws/cli-reference/info.md +++ b/docs/providers/aws/cli-reference/info.md @@ -1,7 +1,7 @@ @@ -76,4 +76,4 @@ CreateThumbnailsLambdaFunctionArn: arn:aws:lambda:us-east-1:377024778620:functio TakeScreenshotLambdaFunctionArn: arn:aws:lambda:us-east-1:377024778620:function:lambda-screenshots-dev-takeScreenshot ServiceEndpoint: https://12341jc801.execute-api.us-east-1.amazonaws.com/dev ServerlessDeploymentBucketName: lambda-screenshots-dev-serverlessdeploymentbucket-15b7pkc04f98a -``` \ No newline at end of file +``` diff --git a/docs/providers/aws/cli-reference/metrics.md b/docs/providers/aws/cli-reference/metrics.md new file mode 100644 index 000000000..5cd708acb --- /dev/null +++ b/docs/providers/aws/cli-reference/metrics.md @@ -0,0 +1,47 @@ + + + +### [Read this on the main serverless docs site](https://www.serverless.com/framework/docs/providers/aws/cli-reference/metrics) + + +# Metrics + +Lets you watch the metrics of a specific function. + +```bash +serverless metrics --function hello +``` + +## Options + +- `--function` or `-f` The function you want to fetch the metrics for. **Required** +- `--stage` or `-s` The stage you want to view the function metrics for. If not provided, the plugin will use the default stage listed in `serverless.yml`. If that doesn't exist either it'll just fetch the metrics from the `dev` stage. +- `--region` or `-r` The region you want to view the function metrics for. If not provided, the plugin will use the default region listed in `serverless.yml`. If that doesn't exist either it'll just fetch the metrics from the `us-east-1` region. +- `--startTime` A specific unit in time to start fetching metrics from (ie: `2010-10-20`, `1469705761`, `30m` (30 minutes ago), `2h` (2 days ago) or `3d` (3 days ago)). Date formats should be written in ISO 8601. Defaults to 24h ago. +- `--endTime` A specific unit in time to end fetching metrics from (ie: `2010-10-21` or `1469705761`). Date formats should be written in ISO 8601. Defaults to now. + +## Examples + +**Note:** There's a small lag between invoking the function and actually having access to the metrics. It takes a few seconds for the metrics to show up right after invoking the function. + +### See all metrics of the last 24h + +```bash +serverless metrics --function hello +``` + +Displays all metrics for the last 24h. + +### See metrics for a specific timespan + +```bash +serverless metrics --function hello --startTime 1970-01-01 --endTime 1970-01-02 +``` + +Displays all metrics for the time between January 1, 1970 and January 2, 1970. diff --git a/docs/providers/aws/cli-reference/remove.md b/docs/providers/aws/cli-reference/remove.md index 57e8b954e..0e7e3e457 100644 --- a/docs/providers/aws/cli-reference/remove.md +++ b/docs/providers/aws/cli-reference/remove.md @@ -1,7 +1,7 @@ diff --git a/docs/providers/aws/cli-reference/rollback.md b/docs/providers/aws/cli-reference/rollback.md index 33a5e7a04..4dd4b1c0c 100644 --- a/docs/providers/aws/cli-reference/rollback.md +++ b/docs/providers/aws/cli-reference/rollback.md @@ -1,7 +1,7 @@ diff --git a/docs/providers/aws/cli-reference/slstats.md b/docs/providers/aws/cli-reference/slstats.md index 0665f014d..2671f8eea 100644 --- a/docs/providers/aws/cli-reference/slstats.md +++ b/docs/providers/aws/cli-reference/slstats.md @@ -1,7 +1,7 @@ diff --git a/lib/plugins/Plugins.json b/lib/plugins/Plugins.json index e88707bc1..0199a86c2 100644 --- a/lib/plugins/Plugins.json +++ b/lib/plugins/Plugins.json @@ -8,6 +8,7 @@ "./invoke/invoke.js", "./info/info.js", "./logs/logs.js", + "./metrics/metrics.js", "./remove/remove.js", "./rollback/index.js", "./slstats/slstats.js", @@ -17,6 +18,7 @@ "./aws/invoke/index.js", "./aws/info/index.js", "./aws/logs/index.js", + "./aws/metrics/awsMetrics.js", "./aws/remove/index.js", "./aws/rollback/index.js", "./aws/deploy/compile/functions/index.js", diff --git a/lib/plugins/aws/metrics/awsMetrics.js b/lib/plugins/aws/metrics/awsMetrics.js new file mode 100644 index 000000000..f4e47f0dd --- /dev/null +++ b/lib/plugins/aws/metrics/awsMetrics.js @@ -0,0 +1,212 @@ +'use strict'; + +const BbPromise = require('bluebird'); +const chalk = require('chalk'); +const _ = require('lodash'); +const moment = require('moment'); +const validate = require('../lib/validate'); + +class AwsMetrics { + constructor(serverless, options) { + this.serverless = serverless; + this.options = options; + this.provider = this.serverless.getProvider('aws'); + + Object.assign(this, validate); + + this.hooks = { + 'metrics:metrics': () => BbPromise.bind(this) + .then(this.extendedValidate) + .then(this.getMetrics) + .then(this.showMetrics), + }; + } + + extendedValidate() { + this.validate(); + + // validate function exists in service + this.options.function = this.serverless.service.getFunction(this.options.function).name; + + const today = new Date(); + let yesterday = new Date(); + yesterday = yesterday.setDate(yesterday.getDate() - 1); + yesterday = new Date(yesterday); + + if (this.options.startTime) { + const since = (['m', 'h', 'd'] + .indexOf(this.options.startTime[this.options.startTime.length - 1]) !== -1); + if (since) { + this.options.startTime = moment().subtract(this.options + .startTime.replace(/\D/g, ''), this.options + .startTime.replace(/\d/g, '')).valueOf(); + } + } else { + this.options.startTime = yesterday; + } + + this.options.endTime = this.options.endTime || today; + + // finally create a new date object + this.options.startTime = new Date(this.options.startTime); + this.options.endTime = new Date(this.options.endTime); + + return BbPromise.resolve(); + } + + getMetrics() { + const FunctionName = this.options.function; + const StartTime = this.options.startTime; + const EndTime = this.options.endTime; + const Namespace = 'AWS/Lambda'; + + const hoursDiff = Math.abs(EndTime - StartTime) / 36e5; + const Period = (hoursDiff > 24) ? 3600 * 24 : 3600; + + const promises = []; + + // get invocations + const invocationsPromise = this.provider.request('CloudWatch', + 'getMetricStatistics', + { + StartTime, + EndTime, + MetricName: 'Invocations', + Namespace, + Period, + Dimensions: [ + { + Name: 'FunctionName', + Value: FunctionName, + }, + ], + Statistics: [ + 'Sum', + ], + Unit: 'Count', + }, + this.options.stage, + this.options.region + ); + // get throttles + const throttlesPromise = this.provider.request('CloudWatch', + 'getMetricStatistics', + { + StartTime, + EndTime, + MetricName: 'Throttles', + Namespace, + Period, + Dimensions: [ + { + Name: 'FunctionName', + Value: FunctionName, + }, + ], + Statistics: [ + 'Sum', + ], + Unit: 'Count', + }, + this.options.stage, + this.options.region + ); + // get errors + const errorsPromise = this.provider.request('CloudWatch', + 'getMetricStatistics', + { + StartTime, + EndTime, + MetricName: 'Errors', + Namespace, + Period, + Dimensions: [ + { + Name: 'FunctionName', + Value: FunctionName, + }, + ], + Statistics: [ + 'Sum', + ], + Unit: 'Count', + }, + this.options.stage, + this.options.region + ); + // get avg. duration + const avgDurationPromise = this.provider.request('CloudWatch', + 'getMetricStatistics', + { + StartTime, + EndTime, + MetricName: 'Duration', + Namespace, + Period, + Dimensions: [ + { + Name: 'FunctionName', + Value: FunctionName, + }, + ], + Statistics: [ + 'Average', + ], + Unit: 'Milliseconds', + }, + this.options.stage, + this.options.region + ); + + // push all promises to the array which will be used to resolve those + promises.push(invocationsPromise); + promises.push(throttlesPromise); + promises.push(errorsPromise); + promises.push(avgDurationPromise); + + return BbPromise.all(promises).then((metrics) => metrics); + } + + showMetrics(metrics) { + let message = ''; + + message += `${chalk.yellow.underline(this.options.function)}\n`; + + const formattedStartTime = moment(this.options.startTime).format('LLL'); + const formattedEndTime = moment(this.options.endTime).format('LLL'); + message += `${formattedStartTime} - ${formattedEndTime}\n\n`; + + if (metrics && metrics.length > 0) { + _.forEach(metrics, (metric) => { + if (metric.Label === 'Invocations') { + const datapoints = metric.Datapoints; + const invocations = datapoints + .reduce((previous, datapoint) => previous + datapoint.Sum, 0); + message += `${chalk.yellow('Invocations:', invocations, '\n')}`; + } else if (metric.Label === 'Throttles') { + const datapoints = metric.Datapoints; + const throttles = datapoints + .reduce((previous, datapoint) => previous + datapoint.Sum, 0); + message += `${chalk.yellow('Throttles:', throttles, '\n')}`; + } else if (metric.Label === 'Errors') { + const datapoints = metric.Datapoints; + const errors = datapoints + .reduce((previous, datapoint) => previous + datapoint.Sum, 0); + message += `${chalk.yellow('Errors:', errors, '\n')}`; + } else { + const datapoints = metric.Datapoints; + const duration = datapoints + .reduce((previous, datapoint) => previous + datapoint.Average, 0); + const formattedRoundedAvgDuration = `${Math.round(duration * 100) / 100}ms`; + message += `${chalk.yellow('Duration (avg.):', formattedRoundedAvgDuration)}`; + } + }); + } else { + message += `${chalk.yellow('There are no metrics to show for these options')}`; + } + this.serverless.cli.consoleLog(message); + return BbPromise.resolve(message); + } +} + +module.exports = AwsMetrics; diff --git a/lib/plugins/aws/metrics/awsMetrics.test.js b/lib/plugins/aws/metrics/awsMetrics.test.js new file mode 100644 index 000000000..d6a30f084 --- /dev/null +++ b/lib/plugins/aws/metrics/awsMetrics.test.js @@ -0,0 +1,319 @@ +'use strict'; + +const expect = require('chai').expect; +const sinon = require('sinon'); +const BbPromise = require('bluebird'); +const AwsProvider = require('../provider/awsProvider'); +const AwsMetrics = require('./awsMetrics'); +const Serverless = require('../../../Serverless'); +const CLI = require('../../../classes/CLI'); +const chalk = require('chalk'); + +describe('AwsMetrics', () => { + let awsMetrics; + let serverless; + + beforeEach(() => { + serverless = new Serverless(); + serverless.cli = new CLI(serverless); + serverless.setProvider('aws', new AwsProvider(serverless)); + const options = { + stage: 'dev', + region: 'us-east-1', + }; + awsMetrics = new AwsMetrics(serverless, options); + }); + + describe('#constructor()', () => { + it('should set the serverless instance to this.serverless', () => { + expect(awsMetrics.serverless).to.deep.equal(serverless); + }); + + it('should set the passed in options to this.options', () => { + expect(awsMetrics.options).to.deep.equal({ stage: 'dev', region: 'us-east-1' }); + }); + + it('should set the provider variable to the AwsProvider instance', () => + expect(awsMetrics.provider).to.be.instanceof(AwsProvider)); + + it('should have a "metrics:metrics" hook', () => { + // eslint-disable-next-line no-unused-expressions + expect(awsMetrics.hooks['metrics:metrics']).to.not.be.undefined; + }); + + it('should run promise chain in order for "metrics:metrics" hook', () => { + const extendedValidateStub = sinon + .stub(awsMetrics, 'extendedValidate').returns(BbPromise.resolve()); + const getMetricsStub = sinon + .stub(awsMetrics, 'getMetrics').returns(BbPromise.resolve()); + const showMetricsStub = sinon + .stub(awsMetrics, 'showMetrics').returns(BbPromise.resolve()); + + return awsMetrics.hooks['metrics:metrics']().then(() => { + expect(extendedValidateStub.calledOnce).to.equal(true); + expect(getMetricsStub.calledAfter(extendedValidateStub)).to.equal(true); + expect(showMetricsStub.calledAfter(getMetricsStub)).to.equal(true); + + awsMetrics.extendedValidate.restore(); + awsMetrics.getMetrics.restore(); + awsMetrics.showMetrics.restore(); + }); + }); + }); + + describe('#extendedValidate()', () => { + let validateStub; + + beforeEach(() => { + awsMetrics.serverless.service.functions = { + function1: {}, + }; + awsMetrics.serverless.service.service = 'my-service'; + awsMetrics.options.function = 'function1'; + validateStub = sinon + .stub(awsMetrics, 'validate').returns(BbPromise.resolve); + }); + + afterEach(() => { + awsMetrics.validate.restore(); + }); + + it('should call the shared validate() function', () => + awsMetrics.extendedValidate().then(() => { + expect(validateStub.calledOnce).to.equal(true); + }) + ); + + it('should set the startTime to yesterday as the default value if not provided', () => { + awsMetrics.options.startTime = null; + + let yesterday = new Date(); + yesterday = yesterday.setDate(yesterday.getDate() - 1); + yesterday = new Date(yesterday); + const yesterdaysYear = yesterday.getFullYear(); + const yesterdaysMonth = yesterday.getMonth() + 1; + const yesterdaysDay = yesterday.getDate(); + const yesterdaysDate = `${yesterdaysYear}-${yesterdaysMonth}-${yesterdaysDay}`; + + return awsMetrics.extendedValidate().then(() => { + const defaultsStartTime = awsMetrics.options.startTime; + const defaultsYear = defaultsStartTime.getFullYear(); + const defaultsMonth = defaultsStartTime.getMonth() + 1; + const defaultsDay = defaultsStartTime.getDate(); + const defaultsDate = `${defaultsYear}-${defaultsMonth}-${defaultsDay}`; + + expect(defaultsDate).to.equal(yesterdaysDate); + }); + }); + + it('should set the startTime to the provided value', () => { + awsMetrics.options.startTime = '1970-01-01'; + + return awsMetrics.extendedValidate().then(() => { + const startTime = awsMetrics.options.startTime.toISOString(); + const expectedStartTime = new Date('1970-01-01').toISOString(); + + expect(startTime).to.equal(expectedStartTime); + }); + }); + + it('should translate human friendly syntax (e.g. 24h) for startTime', () => { + awsMetrics.options.startTime = '24h'; // 24 hours ago + + let yesterday = new Date(); + yesterday = yesterday.setDate(yesterday.getDate() - 1); + yesterday = new Date(yesterday); + const yesterdaysYear = yesterday.getFullYear(); + const yesterdaysMonth = yesterday.getMonth() + 1; + const yesterdaysDay = yesterday.getDate(); + const yesterdaysDate = `${yesterdaysYear}-${yesterdaysMonth}-${yesterdaysDay}`; + + return awsMetrics.extendedValidate().then(() => { + const translatedStartTime = awsMetrics.options.startTime; + const translatedYear = translatedStartTime.getFullYear(); + const translatedMonth = translatedStartTime.getMonth() + 1; + const translatedDay = translatedStartTime.getDate(); + const translatedDate = `${translatedYear}-${translatedMonth}-${translatedDay}`; + + expect(translatedDate).to.equal(yesterdaysDate); + }); + }); + + it('should set the endTime to today as the default value if not provided', () => { + awsMetrics.options.endTime = null; + + const today = new Date(); + const todaysYear = today.getFullYear(); + const todaysMonth = today.getMonth() + 1; + const todaysDay = today.getDate(); + const todaysDate = `${todaysYear}-${todaysMonth}-${todaysDay}`; + + return awsMetrics.extendedValidate().then(() => { + const defaultsStartTime = awsMetrics.options.endTime; + const defaultsYear = defaultsStartTime.getFullYear(); + const defaultsMonth = defaultsStartTime.getMonth() + 1; + const defaultsDay = defaultsStartTime.getDate(); + const defaultsDate = `${defaultsYear}-${defaultsMonth}-${defaultsDay}`; + + expect(defaultsDate).to.equal(todaysDate); + }); + }); + + it('should set the endTime to the provided value', () => { + awsMetrics.options.endTime = '1970-01-01'; + + return awsMetrics.extendedValidate().then(() => { + const endTime = awsMetrics.options.endTime.toISOString(); + const expectedEndTime = new Date('1970-01-01').toISOString(); + + expect(endTime).to.equal(expectedEndTime); + }); + }); + }); + + describe('#getMetrics()', () => { + let requestStub; + + beforeEach(() => { + awsMetrics.options.function = 'function1'; + awsMetrics.options.startTime = '1970-01-01'; + awsMetrics.options.endTime = '1970-01-02'; + requestStub = sinon.stub(awsMetrics.provider, 'request'); + }); + + afterEach(() => { + awsMetrics.provider.request.restore(); + }); + + it('should should gather metrics for the function', () => { + // invocations + requestStub.onCall(0).returns( + BbPromise.resolve({ + ResponseMetadata: { RequestId: '1f50045b-b569-11e6-86c6-eb54d1aaa755' }, + Label: 'Invocations', + Datapoints: [], + }) + ); + // throttles + requestStub.onCall(1).returns( + BbPromise.resolve({ + ResponseMetadata: { RequestId: '1f59059b-b569-11e6-aa18-c7bab68810d2' }, + Label: 'Throttles', + Datapoints: [], + }) + ); + // errors + requestStub.onCall(2).returns( + BbPromise.resolve({ + ResponseMetadata: { RequestId: '1f50c7b1-b569-11e6-b1b6-ab86694b617b' }, + Label: 'Errors', + Datapoints: [], + }) + ); + // duration + requestStub.onCall(3).returns( + BbPromise.resolve({ + ResponseMetadata: { RequestId: '1f63db14-b569-11e6-8501-d98a275ce164' }, + Label: 'Duration', + Datapoints: [], + }) + ); + + const expectedResult = [ + { ResponseMetadata: { RequestId: '1f50045b-b569-11e6-86c6-eb54d1aaa755' }, + Label: 'Invocations', + Datapoints: [], + }, + { ResponseMetadata: { RequestId: '1f59059b-b569-11e6-aa18-c7bab68810d2' }, + Label: 'Throttles', + Datapoints: [], + }, + { ResponseMetadata: { RequestId: '1f50c7b1-b569-11e6-b1b6-ab86694b617b' }, + Label: 'Errors', + Datapoints: [], + }, + { ResponseMetadata: { RequestId: '1f63db14-b569-11e6-8501-d98a275ce164' }, + Label: 'Duration', + Datapoints: [], + }, + ]; + + return awsMetrics.getMetrics().then((result) => { + expect(result).to.deep.equal(expectedResult); + }); + }); + }); + + describe('#showMetrics()', () => { + let consoleLogStub; + + beforeEach(() => { + awsMetrics.options.function = 'function1'; + awsMetrics.options.startTime = '1970-01-01'; + awsMetrics.options.endTime = '1970-01-02'; + consoleLogStub = sinon.stub(serverless.cli, 'consoleLog').returns(); + }); + + afterEach(() => { + serverless.cli.consoleLog.restore(); + }); + + it('should display all metrics for the given function', () => { + const metrics = [ + { + ResponseMetadata: { + RequestId: '1f50045b-b569-11e6-86c6-eb54d1aaa755', + }, + Label: 'Invocations', + Datapoints: [{ Sum: 12 }, { Sum: 8 }], + }, + { + ResponseMetadata: { + RequestId: '1f59059b-b569-11e6-aa18-c7bab68810d2', + }, + Label: 'Throttles', + Datapoints: [{ Sum: 15 }, { Sum: 15 }], + }, + { + ResponseMetadata: { + RequestId: '1f50c7b1-b569-11e6-b1b6-ab86694b617b', + }, + Label: 'Errors', + Datapoints: [{ Sum: 0 }], + }, + { + ResponseMetadata: { + RequestId: '1f63db14-b569-11e6-8501-d98a275ce164', + }, + Label: 'Duration', + Datapoints: [{ Average: 1000 }], + }, + ]; + + let expectedMessage = ''; + expectedMessage += `${chalk.yellow.underline(awsMetrics.options.function)}\n`; + expectedMessage += 'January 1, 1970 12:00 AM - January 2, 1970 12:00 AM\n\n'; + expectedMessage += `${chalk.yellow('Invocations: 20 \n')}`; + expectedMessage += `${chalk.yellow('Throttles: 30 \n')}`; + expectedMessage += `${chalk.yellow('Errors: 0 \n')}`; + expectedMessage += `${chalk.yellow('Duration (avg.): 1000ms')}`; + + return awsMetrics.showMetrics(metrics).then((message) => { + expect(consoleLogStub.calledOnce).to.equal(true); + expect(message).to.equal(expectedMessage); + }); + }); + + it('should resolve with an error message if no metrics are available', () => { + let expectedMessage = ''; + expectedMessage += `${chalk.yellow.underline(awsMetrics.options.function)}\n`; + expectedMessage += 'January 1, 1970 12:00 AM - January 2, 1970 12:00 AM\n\n'; + expectedMessage += `${chalk.yellow('There are no metrics to show for these options')}`; + + return awsMetrics.showMetrics().then((message) => { + expect(consoleLogStub.calledOnce).to.equal(true); + expect(message).to.equal(expectedMessage); + }); + }); + }); +}); diff --git a/lib/plugins/metrics/metrics.js b/lib/plugins/metrics/metrics.js new file mode 100644 index 000000000..3cc54bf4b --- /dev/null +++ b/lib/plugins/metrics/metrics.js @@ -0,0 +1,40 @@ +'use strict'; + +class Metrics { + constructor(serverless, options) { + this.serverless = serverless; + this.options = options; + + this.commands = { + metrics: { + usage: 'Show metrics for a specific function', + lifecycleEvents: [ + 'metrics', + ], + options: { + function: { + usage: 'The function name', + required: true, + shortcut: 'f', + }, + stage: { + usage: 'Stage of the service', + shortcut: 's', + }, + region: { + usage: 'Region of the service', + shortcut: 'r', + }, + startTime: { + usage: 'Start time for the metrics retrieval (e.g. 1970-01-01)', + }, + endTime: { + usage: 'End time for the metrics retrieval (e.g. 1970-01-01)', + }, + }, + }, + }; + } +} + +module.exports = Metrics; diff --git a/lib/plugins/metrics/metrics.test.js b/lib/plugins/metrics/metrics.test.js new file mode 100644 index 000000000..7ad7afb97 --- /dev/null +++ b/lib/plugins/metrics/metrics.test.js @@ -0,0 +1,34 @@ +'use strict'; + +const expect = require('chai').expect; +const Metrics = require('./metrics'); +const Serverless = require('../../Serverless'); + +describe('Metrics', () => { + let metrics; + let serverless; + + beforeEach(() => { + serverless = new Serverless(); + const options = {}; + metrics = new Metrics(serverless, options); + }); + + describe('#constructor()', () => { + it('should have the command "metrics"', () => { + // eslint-disable-next-line no-unused-expressions + expect(metrics.commands.metrics).to.not.be.undefined; + }); + + it('should have a lifecycle event "metrics"', () => { + expect(metrics.commands.metrics.lifecycleEvents).to.deep.equal([ + 'metrics', + ]); + }); + + it('should have a required option "function"', () => { + // eslint-disable-next-line no-unused-expressions + expect(metrics.commands.metrics.options.function.required).to.be.true; + }); + }); +});