Repository: ACloudGuru/serverless-plugin-aws-alerts
Pull request #97 (2019-07-11 19:53:40)

Added custom naming support
Was not able to update #42, thus opening a new PR.

## What did you implement:

Sometimes it's needed to control naming for alarms. Some use cases are:
1. Need to mark important alarms with keyword
1. Make alarm names compliant with company standards
1. Make alarm names more human-readable, especially when using nested stacks

## How did you implement it:

Added `nameTemplate` property to config (root level and alarm level) that supports interpolation.

## How can we verify it:

Use template like `$[functionName]-$[metricName]-Alarm`, and check that CF resource has `AlarmName` property formatted according to your template.

## Todos:

- [x] Write tests
- [x] Write documentation
- [x] Fix linting errors
- [x] Provide verification config/commands/resources




Patch:
diff --git a/.gitignore b/.gitignore
index 03297e4..e69ed29 100644
--- a/.gitignore
+++ b/.gitignore
@@ -40,5 +40,8 @@ jspm_packages
 
 .vscode
 
-#vim swap files
+# vim swap files
 *.swp
+
+# macOS internals
+.DS_Store
diff --git a/README.md b/README.md
index 9306f17..cbac742 100644
--- a/README.md
+++ b/README.md
@@ -25,6 +25,8 @@ custom:
 
     dashboards: true
 
+    nameTemplate: $[functionName]-$[metricName]-Alarm # Optionally - naming template for alarms, can be overwritten in definitions
+
     topics:
       ok: ${self:service}-${opt:stage}-alerts-ok
       alarm: ${self:service}-${opt:stage}-alerts-alarm
@@ -35,6 +37,7 @@ custom:
       customAlarm:
         description: 'My custom alarm'
         namespace: 'AWS/Lambda'
+        nameTemplate: $[functionName]-Duration-IMPORTANT-Alarm # Optionally - naming template for the alarms, overwrites globally defined one
         metric: duration
         threshold: 200
         statistic: Average
@@ -142,6 +145,15 @@ custom:
 
 > Note: For custom log metrics, namespace property will automatically be set to stack name (e.g. `fooservice-dev`).
 
+## Custom Naming
+You can define custom naming template for the alarms. `nameTemplate` property under `alerts` configures naming template for all the alarms, while placing `nameTemplate` under alarm definition configures (overwrites) it for that specific alarm only. Naming template provides interpolation capabilities, where supported placeholders are:
+  - `$[functionName]` - function name (e.g. `helloWorld`)
+  - `$[functionId]` - function logical id (e.g. `HelloWorldLambdaFunction`)
+  - `$[metricName]` - metric name (e.g. `Duration`)
+  - `$[metricId]` - metric id (e.g. `BunyanErrorsHelloWorldLambdaFunction` for the log based alarms, `$[metricName]` otherwise)
+
+> Note: All the alarm names are prefixed with stack name (e.g. `fooservice-dev`).
+
 ## Default Definitions
 The plugin provides some default definitions that you can simply drop into your application. For example:
 
diff --git a/src/index.js b/src/index.js
index 5902ad4..5559aac 100644
--- a/src/index.js
+++ b/src/index.js
@@ -69,7 +69,7 @@ class AlertsPlugin {
     return this.getAlarms(alarms, definitions);
   }
 
-  getAlarmCloudFormation(alertTopics, definition, functionRef) {
+  getAlarmCloudFormation(alertTopics, definition, functionName, functionRef) {
     if (!functionRef) {
       return;
     }
@@ -90,11 +90,12 @@ class AlertsPlugin {
       insufficientDataActions.push(alertTopics.insufficientData);
     }
 
+    const stackName = this.awsProvider.naming.getStackName();
     const namespace = definition.pattern ?
-      this.awsProvider.naming.getStackName() :
+      stackName :
       definition.namespace;
 
-    const metricName = definition.pattern ?
+    const metricId = definition.pattern ?
       this.naming.getPatternMetricName(definition.metric, functionRef) :
       definition.metric;
 
@@ -106,7 +107,7 @@ class AlertsPlugin {
       Type: 'AWS::CloudWatch::Alarm',
       Properties: {
         Namespace: namespace,
-        MetricName: metricName,
+        MetricName: metricId,
         AlarmDescription: definition.description,
         Threshold: definition.threshold,
         Period: definition.period,
@@ -121,6 +122,17 @@ class AlertsPlugin {
       }
     };
 
+    if (definition.nameTemplate) {
+      alarm.Properties.AlarmName = this.naming.getAlarmName({
+        template: definition.nameTemplate,
+        functionLogicalId: functionRef,
+        metricName: definition.metric,
+        metricId,
+        functionName,
+        stackName
+      });
+    }
+
     const statisticValues = [ 'SampleCount', 'Average', 'Sum', 'Minimum', 'Maximum'];
     if (_.includes(statisticValues, definition.statistic)) {
       alarm.Properties.Statistic = definition.statistic
@@ -228,11 +240,11 @@ class AlertsPlugin {
       const normalizedFunctionName = this.providerNaming.getLambdaLogicalId(functionName);
 
       const functionAlarms = this.getFunctionAlarms(functionObj, config, definitions);
-      const alarms = globalAlarms.concat(functionAlarms);
+      const alarms = globalAlarms.concat(functionAlarms).map(alarm => _.assign({ nameTemplate: config.nameTemplate }, alarm));
 
       const alarmStatements = alarms.reduce((statements, alarm) => {
         const key = this.naming.getAlarmCloudFormationRef(alarm.name, functionName);
-        const cf = this.getAlarmCloudFormation(alertTopics, alarm, normalizedFunctionName);
+        const cf = this.getAlarmCloudFormation(alertTopics, alarm, functionName, normalizedFunctionName);
 
         statements[key] = cf;
 
diff --git a/src/naming.js b/src/naming.js
index 3e5a5b3..e00922f 100644
--- a/src/naming.js
+++ b/src/naming.js
@@ -40,6 +40,15 @@ class Naming {
     return filteredDimensions
   }
 
+  getAlarmName(options) {
+    const interpolatedTemplate = options.template
+      .replace('$[functionName]', options.functionName)
+      .replace('$[functionId]', options.functionLogicalId)
+      .replace('$[metricName]', options.metricName)
+      .replace('$[metricId]', options.metricId);
+
+    return `${options.stackName}-${interpolatedTemplate}`;
+  }
 }
 
 module.exports = Naming;


Test patch:
diff --git a/src/index.test.js b/src/index.test.js
index cc42055..1e06286 100644
--- a/src/index.test.js
+++ b/src/index.test.js
@@ -489,6 +489,7 @@ describe('#index', function () {
         }
       });
     });
+
     it('should compile log metric function alarms', () => {
       let config = {
         definitions: {
@@ -561,6 +562,92 @@ describe('#index', function () {
       });
     });
 
+    it('should use globally defined nameTemplate when it`s not provided in definitions', function() {
+      let config = {
+        nameTemplate: '$[functionName]-global',
+        function: ['functionErrors']
+      };
+
+      const plugin = pluginFactory(config);
+
+      config = plugin.getConfig();
+      const definitions = plugin.getDefinitions(config);
+      const alertTopics = plugin.compileAlertTopics(config);
+
+      plugin.compileAlarms(config, definitions, alertTopics);
+      expect(plugin.serverless.service.provider.compiledCloudFormationTemplate.Resources).toEqual({
+        FooFunctionErrorsAlarm: {
+          Type: 'AWS::CloudWatch::Alarm',
+          Properties: {
+            AlarmName: 'fooservice-dev-foo-global',
+            Namespace: 'AWS/Lambda',
+            MetricName: 'Errors',
+            Threshold: 1,
+            Statistic: 'Sum',
+            Period: 60,
+            EvaluationPeriods: 1,
+            DatapointsToAlarm: 1,
+            ComparisonOperator: 'GreaterThanOrEqualToThreshold',
+            AlarmActions: [],
+            OKActions: [],
+            InsufficientDataActions: [],
+            Dimensions: [{
+              Name: 'FunctionName',
+              Value: {
+                Ref: 'FooLambdaFunction'
+              },
+            }],
+            TreatMissingData: 'missing',
+          }
+        }
+      });
+    });
+
+    it('should overwrite globally defined nameTemplate using definitions', function() {
+      let config = {
+        nameTemplate: '$[functionName]-global',
+        definitions: {
+          functionErrors: {
+            nameTemplate: '$[functionName]-local'
+          }
+        },
+        function: ['functionErrors']
+      };
+
+      const plugin = pluginFactory(config);
+
+      config = plugin.getConfig();
+      const definitions = plugin.getDefinitions(config);
+      const alertTopics = plugin.compileAlertTopics(config);
+
+      plugin.compileAlarms(config, definitions, alertTopics);
+      expect(plugin.serverless.service.provider.compiledCloudFormationTemplate.Resources).toEqual({
+        FooFunctionErrorsAlarm: {
+          Type: 'AWS::CloudWatch::Alarm',
+          Properties: {
+            AlarmName: 'fooservice-dev-foo-local',
+            Namespace: 'AWS/Lambda',
+            MetricName: 'Errors',
+            Threshold: 1,
+            Statistic: 'Sum',
+            Period: 60,
+            EvaluationPeriods: 1,
+            DatapointsToAlarm: 1,
+            ComparisonOperator: 'GreaterThanOrEqualToThreshold',
+            AlarmActions: [],
+            OKActions: [],
+            InsufficientDataActions: [],
+            Dimensions: [{
+              Name: 'FunctionName',
+              Value: {
+                Ref: 'FooLambdaFunction'
+              },
+            }],
+            TreatMissingData: 'missing',
+          }
+        }
+      });
+    });
   });
 
   describe('#compileCloudWatchAlarms', () => {
@@ -678,9 +765,10 @@ describe('#index', function () {
         treatMissingData: 'breaching',
       };
 
+      const functionName = 'func-name';
       const functionRef = 'func-ref';
 
-      const cf = plugin.getAlarmCloudFormation(alertTopics, definition, functionRef);
+      const cf = plugin.getAlarmCloudFormation(alertTopics, definition, functionName, functionRef);
 
       expect(cf).toEqual({
         Type: 'AWS::CloudWatch::Alarm',
@@ -707,7 +795,7 @@ describe('#index', function () {
       });
     });
 
-    it('should user the CloudFormation value ExtendedStatistic for p values', () => {
+    it('should use the CloudFormation value ExtendedStatistic for p values', () => {
       const alertTopics = {
         ok: 'ok-topic',
         alarm: 'alarm-topic',
@@ -726,9 +814,10 @@ describe('#index', function () {
         treatMissingData: 'breaching',
       };
 
+      const functionName = 'func-name';
       const functionRef = 'func-ref';
 
-      const cf = plugin.getAlarmCloudFormation(alertTopics, definition, functionRef);
+      const cf = plugin.getAlarmCloudFormation(alertTopics, definition, functionName, functionRef);
 
       expect(cf).toEqual({
         Type: 'AWS::CloudWatch::Alarm',
@@ -774,9 +863,10 @@ describe('#index', function () {
         dimensions: [{'Name':'Cow', 'Value':'MOO'}, {'Name':'Duck', 'Value':'QUACK'}]
       };
 
+      const functionName = 'func-name';
       const functionRef = 'func-ref';
 
-      const cf = plugin.getAlarmCloudFormation(alertTopics, definition, functionRef);
+      const cf = plugin.getAlarmCloudFormation(alertTopics, definition, functionName, functionRef);
 
       expect(cf).toEqual({
         Type: 'AWS::CloudWatch::Alarm',
@@ -807,6 +897,54 @@ describe('#index', function () {
           TreatMissingData: 'breaching',
         }
       });
-    });    
+    });
+    it('should add AlarmName property when nameTemplate is defined', () => {
+      const alertTopics = {
+        ok: 'ok-topic',
+        alarm: 'alarm-topic',
+        insufficientData: 'insufficientData-topic',
+      };
+
+       const definition = {
+        nameTemplate: '$[functionName]-$[functionId]-$[metricName]-$[metricId]',
+        description: 'An error alarm',
+        namespace: 'AWS/Lambda',
+        metric: 'Errors',
+        threshold: 1,
+        period: 300,
+        evaluationPeriods: 1,
+        comparisonOperator: 'GreaterThanThreshold',
+        treatMissingData: 'breaching',
+      };
+
+       const functionName = 'func-name';
+       const functionRef = 'func-ref';
+
+       const cf = plugin.getAlarmCloudFormation(alertTopics, definition, functionName, functionRef);
+
+       expect(cf).toEqual({
+        Type: 'AWS::CloudWatch::Alarm',
+        Properties: {
+          AlarmName: `fooservice-dev-${functionName}-${functionRef}-${definition.metric}-${definition.metric}`,
+          AlarmDescription: definition.description,
+          Namespace: definition.namespace,
+          MetricName: definition.metric,
+          Threshold: definition.threshold,
+          Period: definition.period,
+          EvaluationPeriods: definition.evaluationPeriods,
+          ComparisonOperator: definition.comparisonOperator,
+          OKActions: ['ok-topic'],
+          AlarmActions: ['alarm-topic'],
+          InsufficientDataActions: ['insufficientData-topic'],
+          Dimensions: [{
+            Name: 'FunctionName',
+            Value: {
+              Ref: functionRef,
+            }
+          }],
+          TreatMissingData: 'breaching',
+        }
+      });
+    });
   })
 });
diff --git a/src/naming.test.js b/src/naming.test.js
index 4c93d05..195ae6e 100644
--- a/src/naming.test.js
+++ b/src/naming.test.js
@@ -52,4 +52,23 @@ describe('#naming', function () {
       expect(actual).toEqual(expected);
     });
   });
+
+  describe('#getAlarmName', () => {
+    let naming = null;
+    beforeEach(() => naming = new Naming());
+
+    it('should interpolate alarm name', () => {
+      const template = '$[functionName]-$[functionId]-$[metricName]-$[metricId]';
+      const functionName = 'function';
+      const functionLogicalId = 'functionId';
+      const metricName = 'metric';
+      const metricId = 'metricId';
+      const stackName = 'fooservice-dev';
+
+      const expected = `${stackName}-${functionName}-${functionLogicalId}-${metricName}-${metricId}`;
+      const actual = naming.getAlarmName({ template, functionName, functionLogicalId, metricName, metricId, stackName });
+
+      expect(actual).toEqual(expected);
+    });
+  });
 });