├── .eslintrc.json ├── .github ├── pull_request_template.md └── workflows │ └── ci.yml ├── .gitignore ├── .jshintrc ├── .nvmrc ├── CHANGELOG.md ├── LICENSE ├── README.md ├── index.js ├── package-lock.json ├── package.json ├── renovate.json ├── spec ├── src │ └── indexSpec.js └── support │ └── jasmine.json └── src └── summarySet.js /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "mixmax/node" 3 | } -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | > **Note** - Since this is a public repository, make sure that we're not publishing private data in the code, commit comments, or this PR. 4 | 5 | > **Note for reviewers** - Please add a 2nd reviewer if the PR affects more than 15 files or 100 lines (not counting 6 | `package-lock.json`), if it incurs significant risk, or if it is going through a 2nd review+fix cycle. 7 | 8 | ## 📚 Context/Description Behind The Change 9 | 18 | 19 | ## 🚨 Potential Risks & What To Monitor After Deployment 20 | 28 | 29 | ## 🧑‍🔬 How Has This Been Tested? 30 | 36 | 37 | ## 🚚 Release Plan 38 | 44 | 45 | 46 | 47 | 71 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: continuous-integration 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | branches: 9 | - master 10 | 11 | jobs: 12 | checks: 13 | uses: mixmaxhq/github-workflows-public/.github/workflows/checks.yml@main 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | 5 | # Runtime data 6 | pids 7 | *.pid 8 | *.seed 9 | 10 | # Directory for instrumented libs generated by jscoverage/JSCover 11 | lib-cov 12 | 13 | # Coverage directory used by tools like istanbul 14 | coverage 15 | 16 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 17 | .grunt 18 | 19 | # Compiled binary addons (http://nodejs.org/api/addons.html) 20 | build/Release 21 | 22 | # Dependency directory 23 | # Commenting this out is preferred by some people, see 24 | # https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git- 25 | node_modules 26 | 27 | # Users Environment Variables 28 | .lock-wscript 29 | *.lock 30 | 31 | .DS_Store 32 | spec/browser/spec.bundle.js 33 | *.sublime-workspace 34 | -------------------------------------------------------------------------------- /.jshintrc: -------------------------------------------------------------------------------- 1 | { 2 | // If you are using SublimeLinter, after modifying this config file, be sure to close and reopen 3 | // the file(s) you were editing to see the changes take effect. 4 | 5 | // Prohibit the use of undeclared variables. 6 | "undef": true, 7 | 8 | // Make JSHint aware of Node globals. 9 | "node": true, 10 | 11 | // Warn for unused variables and function parameters. 12 | "unused": true, 13 | 14 | "esnext": true 15 | } 16 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 18.13.0 -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | CHANGELOG 2 | ===== 3 | * 1.3.0 Allow setting unit on `put`, `putSummary` and `sample` #33 (thanks @Nevon!) 4 | * 1.2.0 Add the ability to use summary metrics. 5 | * 1.1.0 Add `metric.sample()` 6 | * 1.0.0 Initial release. 7 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Mixmax, Inc 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## cloudwatch-metrics 2 | This module provides a simplified wrapper for creating and publishing 3 | CloudWatch metrics. 4 | 5 | ## Install 6 | ``` 7 | $ npm install cloudwatch-metrics 8 | ``` 9 | or 10 | ``` 11 | $ npm install cloudwatch-metrics --save 12 | ``` 13 | 14 | ## Usage 15 | 16 | ### Initialization 17 | 18 | By default, the library will log metrics to the `us-east-1` region and read 19 | AWS credentials from the AWS SDK's [default environment variables](http://docs.aws.amazon.com/AWSJavaScriptSDK/guide/node-configuring.html#Credentials_from_Environment_Variables). 20 | 21 | If you want to change these values, you can call `initialize`: 22 | 23 | ```js 24 | var cloudwatchMetrics = require('cloudwatch-metrics'); 25 | cloudwatchMetrics.initialize({ 26 | region: 'us-east-1' 27 | }); 28 | ``` 29 | 30 | ### Metric creation 31 | For creating a metric, we simply need to provide the 32 | namespace and the default type of metric: 33 | ```js 34 | var myMetric = new cloudwatchMetrics.Metric('namespace', 'Count'); 35 | ``` 36 | 37 | ### Metric creation - w/ default dimensions 38 | If we want to add our own default dimensions, such as environment information, 39 | we can add it in the following manner: 40 | ```js 41 | var myMetric = new cloudwatchMetrics.Metric('namespace', 'Count', [{ 42 | Name: 'environment', 43 | Value: 'PROD' 44 | }]); 45 | ``` 46 | 47 | ### Metric creation - w/ options 48 | If we want to disable a metric in certain environments (such as local development), 49 | we can make the metric in the following manner: 50 | ```js 51 | // isLocal is a boolean 52 | var isLocal = someWayOfDetermingIfLocal(); 53 | 54 | var myMetric = new cloudwatchMetrics.Metric('namespace', 'Count', [{ 55 | Name: 'environment', 56 | Value: 'PROD' 57 | }], { 58 | enabled: isLocal 59 | }); 60 | ``` 61 | 62 | The full list of configuration options is: 63 | 64 | Option | Purpose 65 | ------ | ------- 66 | enabled | Whether or not we should send the metric to CloudWatch (useful for dev vs prod environments). 67 | sendInterval | The interval in milliseconds at which we send any buffered metrics, defaults to 5000 milliseconds. 68 | sendCallback | A callback to be called when we send metric data to CloudWatch (useful for logging any errors in sending data). 69 | maxCapacity | A maximum number of events to buffer before we send immediately (before the sendInterval is reached). 70 | withTimestamp | Include the timestamp with the metric value. 71 | storageResolution | The metric storage resolution to use in seconds. Set to 1 for high resolution metrics. See (https://aws.amazon.com/blogs/aws/new-high-resolution-custom-metrics-and-alarms-for-amazon-cloudwatch/) 72 | 73 | ### Publishing metric data 74 | Then, whenever we want to publish a metric, we simply do: 75 | ```js 76 | myMetric.put(value, metric, units, additionalDimensions); 77 | ``` 78 | 79 | ### Using summary metrics 80 | 81 | Instead of sending individual data points for your metric, you may want to send 82 | summary metrics. Summary metrics track statistics over time, and send those 83 | statistics to CloudWatch on a configurable interval. For instance, you might 84 | want to know your total network throughput, but you don't care about individual 85 | request size percentiles. You could use `summaryPut` to track this data and send 86 | it to CloudWatch with fewer requests: 87 | 88 | ```js 89 | var metric = new cloudwatchMetrics.Metric('namespace', 'Bytes'); 90 | 91 | function onRequest(req) { 92 | // This will still track maximum, minimum, sum, count, and average, but won't 93 | // take up lots of CloudWatch requests doing so. 94 | metric.summaryPut(req.size, 'requestSize'); 95 | } 96 | ``` 97 | 98 | Note that metrics use different summaries for different dimensions, _and that 99 | the order of the dimensions is significant!_ In other words, these track 100 | different metric sets: 101 | 102 | ```js 103 | var metric = new cloudwatchMetrics.Metric('namespace', 'Bytes'); 104 | // Different statistic sets! 105 | metric.summaryPut(45, 'requestSize', [{Name: 'Region', Value: 'US'}, {Name: 'Server', Value: 'Card'}]); 106 | metric.summaryPut(894, 'requestSize', [{Name: 'Server', Value: 'Card'}, {Name: 'Region', Value: 'US'}]); 107 | ``` 108 | 109 | ### NOTES 110 | Be aware that the `put` call does not actually send the metric to CloudWatch 111 | at that moment. Instead, it stores unsent metrics and sends them to 112 | CloudWatch on a predetermined interval (to help get around sending too many 113 | metrics at once - CloudWatch limits you by default to 150 put-metric data 114 | calls per second). The default interval is 5 seconds, if you want metrics 115 | sent at a different interval, then provide that option when constructing your 116 | CloudWatch Metric: 117 | 118 | ```js 119 | var myMetric = new cloudwatchMetrics.Metric('namespace', 'Count', [{ 120 | Name: 'environment', 121 | Value: 'PROD' 122 | }], { 123 | sendInterval: 3 * 1000 // It's specified in milliseconds. 124 | }); 125 | ``` 126 | 127 | You can also register a callback to be called when we actually send metrics 128 | to CloudWatch - this can be useful for logging put-metric-data errors: 129 | ```js 130 | var myMetric = new cloudwatchMetrics.Metric('namespace', 'Count', [{ 131 | Name: 'environment', 132 | Value: 'PROD' 133 | }], { 134 | sendCallback: (err) => { 135 | if (!err) return; 136 | // Do your error handling here. 137 | } 138 | }); 139 | ``` 140 | 141 | ## Publishing a new version 142 | 143 | ``` 144 | GH_TOKEN=xxx npx semantic-release --no-ci 145 | ``` -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * This module provides a simplified wrapper for creating and publishing 3 | * CloudWatch metrics. We should always initialize our environment first: 4 | * 5 | * ``` 6 | * var cloudwatchMetrics = require('cloudwatch-metrics'); 7 | * cloudwatchMetrics.initialize({ 8 | * region: 'us-east-1' 9 | * }); 10 | * ``` 11 | * 12 | * For creating a metric, we simply need to provide the 13 | * namespace and the type of metric: 14 | * 15 | * ``` 16 | * var myMetric = new cloudwatchMetrics.Metric('namespace', 'Count'); 17 | * ``` 18 | * 19 | * If we want to add our own default dimensions, such as environment information, 20 | * we can add it in the following manner: 21 | * 22 | * ``` 23 | * var myMetric = new cloudwatchMetrics.Metric('namespace', 'Count', [{ 24 | * Name: 'environment', 25 | * Value: 'PROD' 26 | * }]); 27 | * ``` 28 | * 29 | * If we want to disable a metric in certain environments (such as local development), 30 | * we can make the metric in the following manner: 31 | * 32 | * ``` 33 | * // isLocal is a boolean 34 | * var isLocal = someWayOfDetermingIfLocal(); 35 | * 36 | * var myMetric = new cloudwatchMetrics.Metric('namespace', 'Count', [{ 37 | * Name: 'environment', 38 | * Value: 'PROD' 39 | * }], { 40 | * enabled: isLocal 41 | * }); 42 | * ``` 43 | * 44 | * Then, whenever we want to publish a metric, we simply do: 45 | * 46 | * ``` 47 | * myMetric.put(value, metric, additionalDimensions); 48 | * ``` 49 | * 50 | * Be aware that the `put` call does not actually send the metric to CloudWatch 51 | * at that moment. Instead, it stores unsent metrics and sends them to 52 | * CloudWatch on a predetermined interval (to help get around sending too many 53 | * metrics at once - CloudWatch limits you by default to 150 put-metric data 54 | * calls per second). The default interval is 5 seconds, if you want metrics 55 | * sent at a different interval, then provide that option when construction your 56 | * CloudWatch Metric: 57 | * 58 | * ``` 59 | * var myMetric = new cloudwatchMetrics.Metric('namespace', 'Count', [{ 60 | * Name: 'environment', 61 | * Value: 'PROD' 62 | * }], { 63 | * sendInterval: 3 * 1000 // It's specified in milliseconds. 64 | * }); 65 | * ``` 66 | * 67 | * You can also register a callback to be called when we actually send metrics 68 | * to CloudWatch - this can be useful for logging put-metric-data errors: 69 | * ``` 70 | * var myMetric = new cloudwatchMetrics.Metric('namespace', 'Count', [{ 71 | * Name: 'environment', 72 | * Value: 'PROD' 73 | * }], { 74 | * sendCallback: (err) => { 75 | * if (!err) return; 76 | * // Do your error handling here. 77 | * } 78 | * }); 79 | * ``` 80 | */ 81 | 82 | var { CloudWatchClient, PutMetricDataCommand }= require('@aws-sdk/client-cloudwatch'); 83 | const SummarySet = require('./src/summarySet'); 84 | 85 | var _awsConfig = {region: 'us-east-1'}; 86 | /** 87 | * setIndividialConfig sets the default configuration to use when creating AWS 88 | * metrics. It defaults to simply setting the AWS region to `us-east-1`, i.e.: 89 | * 90 | * { 91 | * region: 'us-east-1' 92 | * } 93 | * @param {Object} config The AWS SDK configuration options one would like to set. 94 | */ 95 | function initialize(config) { 96 | _awsConfig = config; 97 | } 98 | 99 | const DEFAULT_METRIC_OPTIONS = { 100 | enabled: true, 101 | sendInterval: 5000, 102 | summaryInterval: 10000, 103 | sendCallback: () => {}, 104 | maxCapacity: 20, 105 | withTimestamp: false, 106 | storageResolution: undefined 107 | }; 108 | 109 | /** 110 | * Create a custom CloudWatch Metric object that sets pre-configured dimensions and allows for 111 | * customized metricName and units. Each CloudWatchMetric object has it's own internal 112 | * CloudWatchClient object to prevent errors due to overlapping callings to 113 | * CloudWatchClient#send. 114 | * 115 | * @param {String} namespace CloudWatch namespace 116 | * @param {String} units CloudWatch units 117 | * @param {Object} defaultDimensions (optional) Any default dimensions we'd 118 | * like the metric to have. 119 | * @param {Object} options (optional) Options used to control metric 120 | * behavior. 121 | * @param {Bool} options.enabled Defaults to true, controls whether we 122 | * publish the metric when `Metric#put()` is called - this is useful for 123 | * turning off metrics in specific environments. 124 | */ 125 | function Metric(namespace, units, defaultDimensions, options) { 126 | var self = this; 127 | self.cloudwatch = new CloudWatchClient(_awsConfig); 128 | self.namespace = namespace; 129 | self.units = units; 130 | self.defaultDimensions = defaultDimensions || []; 131 | self.options = Object.assign({}, DEFAULT_METRIC_OPTIONS, options); 132 | self._storedMetrics = []; 133 | this._summaryData = new Map(); 134 | 135 | if (self.options.enabled) { 136 | self._interval = global.setInterval(() => { 137 | self._sendMetrics(); 138 | }, self.options.sendInterval); 139 | 140 | this._summaryInterval = global.setInterval(() => { 141 | this._summarizeMetrics(); 142 | }, this.options.summaryInterval); 143 | } 144 | } 145 | 146 | /** 147 | * Publish this data to Cloudwatch 148 | * @param {Integer|Long} value Data point to submit 149 | * @param {String} namespace Name of the metric 150 | * @param {String} units CloudWatch units 151 | * @param {Array} [additionalDimensions] Array of additional CloudWatch metric dimensions. See 152 | * http://docs.aws.amazon.com/AmazonCloudWatch/latest/APIReference/API_Dimension.html for details. 153 | */ 154 | Metric.prototype.put = function(...args) { 155 | if (args.length === 3) { 156 | const [value, metricName] = args; 157 | const shouldInheritUnits = Array.isArray(args[2]); 158 | const units = shouldInheritUnits ? this.units : args[2]; 159 | const additionalDimensions = shouldInheritUnits ? args[2] : []; 160 | return this._put(value, metricName, units, additionalDimensions); 161 | } else if (args.length === 2) { 162 | return this._put(...args, this.units); 163 | } 164 | return this._put(...args); 165 | }; 166 | 167 | Metric.prototype._put = function(value, metricName, units, additionalDimensions = []) { 168 | var self = this; 169 | // Only publish if we are enabled 170 | if (self.options.enabled) { 171 | var payload = { 172 | MetricName: metricName, 173 | Dimensions: self.defaultDimensions.concat(additionalDimensions), 174 | Unit: units, 175 | Value: value 176 | }; 177 | if (this.options.withTimestamp) { 178 | payload.Timestamp = new Date().toISOString(); 179 | } 180 | if (this.options.storageResolution) { 181 | payload.StorageResolution = this.options.storageResolution; 182 | } 183 | 184 | self._storedMetrics.push(payload); 185 | 186 | // We need to see if we're at our maxCapacity, if we are - then send the 187 | // metrics now. 188 | if (self._storedMetrics.length === self.options.maxCapacity) { 189 | global.clearInterval(self._interval); 190 | self._sendMetrics(); 191 | self._interval = global.setInterval(() => { 192 | self._sendMetrics(); 193 | }, self.options.sendInterval); 194 | } 195 | } 196 | }; 197 | 198 | /** 199 | * Summarize the data using a statistic set and put it on the configured summary interval. This will 200 | * cause Cloudwatch to be unable to track the value distribution, so it'll only show summation and 201 | * bounds. The order of additionalDimensions is important, and rearranging the order will cause the 202 | * Metric instance to track those two summary sets independently! 203 | * @param {Number} value The value to include in the summary. 204 | * @param {String} metricName The name of the metric we're summarizing. 205 | * @param {String} units CloudWatch units 206 | * @param {Object[]} additionalDimensions The extra dimensions we're tracking. 207 | */ 208 | Metric.prototype.summaryPut = function(...args) { 209 | if (args.length === 3) { 210 | const [value, metricName] = args; 211 | const shouldInheritUnits = Array.isArray(args[2]); 212 | const units = shouldInheritUnits ? this.units : args[2]; 213 | const additionalDimensions = shouldInheritUnits ? args[2] : []; 214 | return this._summaryPut(value, metricName, units, additionalDimensions); 215 | } else if (args.length === 2) { 216 | return this._summaryPut(...args, this.units, []); 217 | } 218 | return this._summaryPut(...args); 219 | }; 220 | 221 | Metric.prototype._summaryPut = function(value, metricName, units, additionalDimensions = []) { 222 | const key = makeKey(metricName, units, additionalDimensions); 223 | const entry = this._summaryData.get(key); 224 | 225 | let set; 226 | if (entry) { 227 | set = entry[3]; 228 | } else { 229 | set = new SummarySet(); 230 | const allDimensions = [...this.defaultDimensions, ...additionalDimensions]; 231 | this._summaryData.set(key, [metricName, units, allDimensions, set]); 232 | } 233 | set.put(value); 234 | }; 235 | 236 | /** 237 | * Samples a metric so that we send the metric to Cloudwatch at the given 238 | * sampleRate. 239 | * @param {Integer|Long} value Data point to submit 240 | * @param {String} namespace Name of the metric 241 | * @param {String} units CloudWatch units 242 | * @param {Array} additionalDimensions Array of additional CloudWatch metric dimensions. See 243 | * http://docs.aws.amazon.com/AmazonCloudWatch/latest/APIReference/API_Dimension.html for details. 244 | * @param {Float} sampleRate The rate at which to sample the metric at. 245 | * The sample rate must be between 0.0 an 1.0. As an example, if you provide 246 | * a sampleRate of 0.1, then we will send the metric to Cloudwatch 10% of the 247 | * time. 248 | */ 249 | Metric.prototype.sample = function(...args) { 250 | if (args.length === 4) { 251 | const [value, metricName, additionalDimensions, sampleRate] = args; 252 | const units = this.units; 253 | return this._sample(value, metricName, units, additionalDimensions, sampleRate); 254 | } 255 | 256 | return this.prototype._sample(...args); 257 | }; 258 | 259 | Metric.prototype._sample = function(value, metricName, units, additionalDimensions, sampleRate) { 260 | sampleRate = Array.isArray(additionalDimensions) ? sampleRate : additionalDimensions; 261 | if (Math.random() < sampleRate) this.put(value, metricName, units, additionalDimensions); 262 | }; 263 | 264 | /** 265 | * _sendMetrics is called on a specified interval (defaults to 5 seconds but 266 | * can be overridden but providing a `sendInterval` option when creating a 267 | * Metric). It is what actually sends metrics to CloudWatch. It passes the 268 | * sendCallback option (if provided) as the callback to the put-metric-data 269 | * call. This can be useful for logging AWS errors. 270 | */ 271 | Metric.prototype._sendMetrics = function() { 272 | var self = this; 273 | // NOTE: this would be racy except that NodeJS is single threaded. 274 | const dataPoints = self._storedMetrics; 275 | self._storedMetrics = []; 276 | 277 | if (!dataPoints || !dataPoints.length) return; 278 | self.cloudwatch.send(new PutMetricDataCommand({ 279 | MetricData: dataPoints, 280 | Namespace: self.namespace 281 | }), self.options.sendCallback); 282 | }; 283 | 284 | /** 285 | * Shuts down metric service by clearing any outstanding timer and sending any existing metrics 286 | */ 287 | Metric.prototype.shutdown = function() { 288 | global.clearInterval(this._interval); 289 | global.clearInterval(this._summaryInterval); 290 | 291 | this._sendMetrics(); 292 | this._summarizeMetrics(); 293 | }; 294 | 295 | /** 296 | * Gets whether outstanding metrics exist or not. 297 | * 298 | * @return {boolean} 299 | */ 300 | Metric.prototype.hasMetrics = function() { 301 | return !!this._storedMetrics.length; 302 | }; 303 | 304 | /** 305 | * _summarizeMetrics is called on a specified interval (default, 10 seconds). It 306 | * sends summarized statistics to Cloudwatch. 307 | */ 308 | Metric.prototype._summarizeMetrics = function() { 309 | const summaryEntries = this._summaryData.values(); 310 | const dataPoints = []; 311 | for (const [MetricName, Unit, Dimensions, set] of summaryEntries) { 312 | if (!set.size) continue; 313 | 314 | dataPoints.push({ 315 | MetricName, 316 | Dimensions, 317 | StatisticValues: set.get(), 318 | Unit, 319 | }); 320 | 321 | if (dataPoints.length === this.options.maxCapacity) { 322 | // Put a copy of the points we've gathered, then empty the array so we can 323 | // get more. 324 | this._putSummaryMetrics(dataPoints.slice()); 325 | dataPoints.length = 0; 326 | } 327 | } 328 | 329 | if (dataPoints.length) { 330 | this._putSummaryMetrics(dataPoints); 331 | } 332 | }; 333 | 334 | /** 335 | * Put a single batch of summarized metrics to Cloudwatch. This helps avoid 336 | * hitting the Cloudwatch per-call maximum. 337 | */ 338 | Metric.prototype._putSummaryMetrics = function(MetricData) { 339 | this.cloudwatch.send(new PutMetricDataCommand({ 340 | MetricData, 341 | Namespace: this.namespace, 342 | }), this.options.sendCallback); 343 | }; 344 | 345 | /** 346 | * Make a key for a given metric name and some dimensions. This works on the 347 | * assumption that sane people won't put a null character in their metric name 348 | * or dimension name/value. 349 | * 350 | * @param {String} metricName 351 | * @param {String} units 352 | * @param {Object[]} dimensions 353 | * @returns {String} Something we can actually use as a Map key. 354 | */ 355 | function makeKey(metricName, units, dimensions) { 356 | let key = `${metricName}\0${units}`; 357 | for (const {Name, Value} of dimensions) { 358 | key += `\0${Name}\0${Value}`; 359 | } 360 | return key; 361 | } 362 | 363 | module.exports = { 364 | initialize, 365 | Metric 366 | }; 367 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cloudwatch-metrics", 3 | "version": "1.3.3", 4 | "description": "A simple wrapper for simplifying using Cloudwatch metrics", 5 | "main": "index.js", 6 | "scripts": { 7 | "ci": "npm run lint && npm test", 8 | "test": "jasmine", 9 | "lint": "eslint ." 10 | }, 11 | "repository": { 12 | "type": "git", 13 | "url": "git+https://github.com/mixmaxhq/cloudwatch-metrics.git" 14 | }, 15 | "keywords": [ 16 | "aws", 17 | "cloudwatch", 18 | "metrics" 19 | ], 20 | "author": "Trey Tacon (https://mixmax.com)", 21 | "license": "MIT", 22 | "bugs": { 23 | "url": "https://github.com/mixmaxhq/cloudwatch-metrics/issues" 24 | }, 25 | "homepage": "https://github.com/mixmaxhq/cloudwatch-metrics#readme", 26 | "dependencies": { 27 | "@aws-sdk/client-cloudwatch": "^3.332.0" 28 | }, 29 | "devDependencies": { 30 | "eslint": ">=3", 31 | "eslint-config-mixmax": "^0.6.0", 32 | "jasmine": "^2.99.0", 33 | "pre-commit": "^1.2.2", 34 | "rewire": "^2.5.2", 35 | "underscore": "^1.8.3" 36 | }, 37 | "pre-commit": [ 38 | "lint" 39 | ] 40 | } 41 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "local>mixmaxhq/renovate-config" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /spec/src/indexSpec.js: -------------------------------------------------------------------------------- 1 | /* globals describe, beforeEach, afterEach, it, expect, spyOn, jasmine */ 2 | 3 | var _ = require('underscore'); 4 | 5 | var rewire = require('rewire'); 6 | var cloudwatchMetric = rewire('../..'); 7 | 8 | describe('cloudwatch-metrics', function() { 9 | var restoreAWS, metric; 10 | 11 | function attachHook(hook) { 12 | restoreAWS = cloudwatchMetric.__set__('CloudWatchClient', function() { 13 | this.send = hook; 14 | }); 15 | } 16 | 17 | afterEach(function() { 18 | if (restoreAWS) { 19 | restoreAWS(); 20 | restoreAWS = null; 21 | } 22 | 23 | if (metric) { 24 | metric.shutdown(); 25 | } 26 | }); 27 | 28 | describe('put', function() { 29 | it('should buffer until timeout', function(done) { 30 | attachHook(function(data, cb) { 31 | expect(data).toEqual(jasmine.objectContaining({input: { 32 | MetricData: [{ 33 | Dimensions: [{ 34 | Name: 'environment', 35 | Value: 'PROD' 36 | }, { 37 | Name: 'ExtraDimension', 38 | Value: 'Value' 39 | }], 40 | MetricName: 'metricName', 41 | Unit: 'Count', 42 | Value: 1 43 | }], 44 | Namespace: 'namespace' 45 | }})); 46 | cb(); 47 | }); 48 | 49 | metric = new cloudwatchMetric.Metric('namespace', 'Count', [{ 50 | Name: 'environment', 51 | Value: 'PROD' 52 | }], { 53 | sendInterval: 1000, 54 | sendCallback: done 55 | }); 56 | 57 | metric.put(1, 'metricName', [{Name:'ExtraDimension',Value: 'Value'}]); 58 | }); 59 | 60 | it('should call continually', function(done) { 61 | attachHook(function(data, cb) { 62 | expect(data).toEqual(jasmine.objectContaining({input: { 63 | MetricData: [{ 64 | Dimensions: [{ 65 | Name: 'environment', 66 | Value: 'PROD' 67 | }, { 68 | Name: 'ExtraDimension', 69 | Value: 'Value' 70 | }], 71 | MetricName: 'metricName', 72 | Unit: 'Count', 73 | Value: 1 74 | }], 75 | Namespace: 'namespace' 76 | }})); 77 | cb(); 78 | }); 79 | 80 | metric = new cloudwatchMetric.Metric('namespace', 'Count', [{ 81 | Name: 'environment', 82 | Value: 'PROD' 83 | }], { 84 | sendInterval: 500, 85 | sendCallback: _.after(3, done) 86 | }); 87 | 88 | var interval; 89 | var stop = _.after(3, () => clearInterval(interval)); 90 | interval = setInterval(function() { 91 | metric.put(1, 'metricName', [{Name:'ExtraDimension',Value: 'Value'}]); 92 | stop(); 93 | }, 400); 94 | }); 95 | 96 | it('should buffer until the cap is hit', function(done) { 97 | attachHook(function(data, cb) { 98 | expect(data).toEqual(jasmine.objectContaining({input: { 99 | MetricData: [{ 100 | Dimensions: [{ 101 | Name: 'environment', 102 | Value: 'PROD' 103 | }, { 104 | Name: 'ExtraDimension', 105 | Value: 'Value' 106 | }], 107 | MetricName: 'metricName', 108 | Unit: 'Count', 109 | Value: 1 110 | }, { 111 | Dimensions: [{ 112 | Name: 'environment', 113 | Value: 'PROD' 114 | }, { 115 | Name: 'ExtraDimension', 116 | Value: 'Value' 117 | }], 118 | MetricName: 'metricName', 119 | Unit: 'Count', 120 | Value: 2 121 | }], 122 | Namespace: 'namespace' 123 | }})); 124 | cb(); 125 | }); 126 | 127 | metric = new cloudwatchMetric.Metric('namespace', 'Count', [{ 128 | Name: 'environment', 129 | Value: 'PROD' 130 | }], { 131 | sendInterval: 3000, // mocha defaults to a 2 second timeout so setting 132 | // larger than that will cause the test to fail if we 133 | // hit the timeout 134 | sendCallback: done, 135 | maxCapacity: 2 136 | }); 137 | 138 | metric.put(1, 'metricName', [{Name:'ExtraDimension',Value: 'Value'}]); 139 | metric.put(2, 'metricName', [{Name:'ExtraDimension',Value: 'Value'}]); 140 | }); 141 | 142 | it('should set a Timestamp if specified in the options', function(done) { 143 | attachHook(function(data, cb) { 144 | expect(data).toEqual(jasmine.objectContaining({input: { 145 | MetricData: [{ 146 | Dimensions: [{ 147 | Name: 'environment', 148 | Value: 'PROD' 149 | }, { 150 | Name: 'ExtraDimension', 151 | Value: 'Value' 152 | }], 153 | MetricName: 'metricName', 154 | Unit: 'Count', 155 | Timestamp: jasmine.any(String), 156 | Value: 1 157 | }], 158 | Namespace: 'namespace' 159 | }})); 160 | expect(Date.parse(data.input.MetricData[0].Timestamp)).toBeLessThanOrEqual(Date.now()); 161 | cb(); 162 | }); 163 | 164 | metric = new cloudwatchMetric.Metric('namespace', 'Count', [{ 165 | Name: 'environment', 166 | Value: 'PROD' 167 | }], { 168 | withTimestamp: true, 169 | sendInterval: 1000, // mocha defaults to a 2 second timeout so setting 170 | // larger than that will cause the test to fail if we 171 | // hit the timeout 172 | sendCallback: done, 173 | }); 174 | 175 | metric.put(1, 'metricName', [{Name: 'ExtraDimension', Value: 'Value'}]); 176 | }); 177 | 178 | it('should set a StorageResolution if specified in the options', function(done) { 179 | attachHook(function(data, cb) { 180 | expect(data).toEqual(jasmine.objectContaining({input: { 181 | MetricData: [{ 182 | Dimensions: [{ 183 | Name: 'environment', 184 | Value: 'PROD' 185 | }, { 186 | Name: 'ExtraDimension', 187 | Value: 'Value' 188 | }], 189 | MetricName: 'metricName', 190 | Unit: 'Count', 191 | StorageResolution: 1, 192 | Value: 1 193 | }], 194 | Namespace: 'namespace' 195 | }})); 196 | cb(); 197 | }); 198 | 199 | metric = new cloudwatchMetric.Metric('namespace', 'Count', [{ 200 | Name: 'environment', 201 | Value: 'PROD' 202 | }], { 203 | storageResolution: 1, 204 | sendInterval: 1000, // mocha defaults to a 2 second timeout so setting 205 | // larger than that will cause the test to fail if we 206 | // hit the timeout 207 | sendCallback: done, 208 | }); 209 | 210 | metric.put(1, 'metricName', [{Name: 'ExtraDimension', Value: 'Value'}]); 211 | }); 212 | 213 | it('should override the Unit from the namespace if specified in the put call', function (done) { 214 | attachHook(function (data, cb) { 215 | expect(data).toEqual(jasmine.objectContaining({input: { 216 | MetricData: [{ 217 | Dimensions: [{ 218 | Name: 'environment', 219 | Value: 'PROD' 220 | }, { 221 | Name: 'ExtraDimension', 222 | Value: 'Value' 223 | }], 224 | MetricName: 'metricName', 225 | Unit: 'Percent', 226 | Value: 1 227 | }], 228 | Namespace: 'namespace' 229 | }})); 230 | cb(); 231 | }); 232 | 233 | const metric = new cloudwatchMetric.Metric('namespace', 'Count', [{ 234 | Name: 'environment', 235 | Value: 'PROD' 236 | }], { 237 | sendInterval: 1000, 238 | sendCallback: done, 239 | }); 240 | 241 | metric.put(1, 'metricName', 'Percent', [{ Name: 'ExtraDimension', Value: 'Value' }]); 242 | }); 243 | }); 244 | 245 | describe('sample', function() { 246 | it('should ignore metrics when not in the sample range', function() { 247 | metric = new cloudwatchMetric.Metric('namespace', 'Count', [{ 248 | Name: 'environment', 249 | Value: 'PROD' 250 | }]); 251 | 252 | spyOn(Math, 'random').and.returnValue(0.5); 253 | spyOn(metric, 'put'); 254 | 255 | metric.sample(1, 'metricName', [{Name:'ExtraDimension',Value: 'Value'}], 0.2); 256 | expect(metric.put).not.toHaveBeenCalled(); 257 | }); 258 | 259 | it('should call put when the we decide to sample a metric', function() { 260 | metric = new cloudwatchMetric.Metric('namespace', 'Count', [{ 261 | Name: 'environment', 262 | Value: 'PROD' 263 | }]); 264 | 265 | spyOn(Math, 'random').and.returnValue(0.1); 266 | // Just so we don't send anything to AWS. 267 | spyOn(metric, 'put').and.returnValue(undefined); 268 | 269 | metric.sample(1, 'metricName', [{Name:'ExtraDimension',Value: 'Value'}], 0.2); 270 | expect(metric.put).toHaveBeenCalled(); 271 | }); 272 | }); 273 | 274 | describe('summaryPut', function() { 275 | it('should not call with no data', function(done) { 276 | attachHook(() => { 277 | throw new Error('should not get send callback'); 278 | }); 279 | 280 | metric = new cloudwatchMetric.Metric('namespace', 'Count', [{ 281 | Name: 'environment', 282 | Value: 'PROD', 283 | }], { 284 | summaryInterval: 100, 285 | sendCallback() { 286 | throw new Error('should not get send callback'); 287 | }, 288 | }); 289 | 290 | spyOn(metric, '_summarizeMetrics'); 291 | 292 | setTimeout(() => { 293 | expect(metric._summarizeMetrics).toHaveBeenCalled(); 294 | done(); 295 | }, 250); 296 | }); 297 | 298 | it('should call with summary', function(done) { 299 | let hookCalls = 0; 300 | attachHook((data, cb) => { 301 | ++hookCalls; 302 | expect(data).toEqual(jasmine.objectContaining({input: { 303 | MetricData: [{ 304 | Dimensions: [{ 305 | Name: 'environment', 306 | Value: 'PROD' 307 | }, { 308 | Name: 'ExtraDimension', 309 | Value: 'Value' 310 | }], 311 | MetricName: 'some-metric', 312 | Unit: 'Count', 313 | StatisticValues: { 314 | Minimum: 12, 315 | Maximum: 13, 316 | Sum: 25, 317 | SampleCount: 2, 318 | }, 319 | }, { 320 | Dimensions: [{ 321 | Name: 'environment', 322 | Value: 'PROD' 323 | }, { 324 | Name: 'ExtraDimension', 325 | Value: 'Value' 326 | }], 327 | MetricName: 'some-other-metric', 328 | Unit: 'Count', 329 | StatisticValues: { 330 | Minimum: 2, 331 | Maximum: 2, 332 | Sum: 2, 333 | SampleCount: 1, 334 | }, 335 | }, { 336 | Dimensions: [{ 337 | Name: 'environment', 338 | Value: 'PROD' 339 | }, { 340 | Name: 'ExtraDimension', 341 | Value: 'Value' 342 | }], 343 | MetricName: 'a-metric-with-different-unit', 344 | Unit: 'Percent', 345 | StatisticValues: { 346 | Minimum: 5, 347 | Maximum: 5, 348 | Sum: 5, 349 | SampleCount: 1, 350 | }, 351 | }], 352 | Namespace: 'namespace' 353 | }})); 354 | cb(); 355 | }); 356 | 357 | metric = new cloudwatchMetric.Metric('namespace', 'Count', [{ 358 | Name: 'environment', 359 | Value: 'PROD', 360 | }], { 361 | summaryInterval: 100, 362 | sendCallback() { 363 | expect(hookCalls).toBe(1); 364 | done(); 365 | }, 366 | }); 367 | 368 | metric.summaryPut(12, 'some-metric', [{ Name: 'ExtraDimension', Value: 'Value' }]); 369 | metric.summaryPut(2, 'some-other-metric', [{ Name: 'ExtraDimension', Value: 'Value' }]); 370 | metric.summaryPut(5, 'a-metric-with-different-unit', 'Percent', [{ Name: 'ExtraDimension', Value: 'Value' }]); 371 | setTimeout(() => { 372 | metric.summaryPut(13, 'some-metric', [{Name: 'ExtraDimension', Value: 'Value'}]); 373 | }, 50); 374 | }); 375 | 376 | it('should call after no data', function(done) { 377 | let hookCalls = 0; 378 | attachHook((data, cb) => { 379 | ++hookCalls; 380 | expect(data).toEqual(jasmine.objectContaining({input: { 381 | MetricData: [{ 382 | Dimensions: [{ 383 | Name: 'environment', 384 | Value: 'PROD' 385 | }, { 386 | Name: 'ExtraDimension', 387 | Value: 'Value' 388 | }], 389 | MetricName: 'some-metric', 390 | Unit: 'Count', 391 | StatisticValues: { 392 | Minimum: 12, 393 | Maximum: 13, 394 | Sum: 25, 395 | SampleCount: 2, 396 | }, 397 | }, { 398 | Dimensions: [{ 399 | Name: 'environment', 400 | Value: 'PROD' 401 | }, { 402 | Name: 'ExtraDimension', 403 | Value: 'Value' 404 | }], 405 | MetricName: 'some-other-metric', 406 | Unit: 'Count', 407 | StatisticValues: { 408 | Minimum: 2, 409 | Maximum: 2, 410 | Sum: 2, 411 | SampleCount: 1, 412 | }, 413 | }], 414 | Namespace: 'namespace' 415 | }})); 416 | cb(); 417 | }); 418 | 419 | metric = new cloudwatchMetric.Metric('namespace', 'Count', [{ 420 | Name: 'environment', 421 | Value: 'PROD', 422 | }], { 423 | summaryInterval: 200, 424 | sendCallback() { 425 | expect(hookCalls).toBe(1); 426 | done(); 427 | }, 428 | }); 429 | 430 | setTimeout(() => { 431 | metric.summaryPut(12, 'some-metric', [{Name: 'ExtraDimension', Value: 'Value'}]); 432 | metric.summaryPut(2, 'some-other-metric', [{Name: 'ExtraDimension', Value: 'Value'}]); 433 | setTimeout(() => { 434 | metric.summaryPut(13, 'some-metric', [{Name: 'ExtraDimension', Value: 'Value'}]); 435 | }, 50); 436 | }, 300); 437 | }); 438 | }); 439 | 440 | describe('shutdown', function () { 441 | let setIntervalSpy, clearIntervalSpy; 442 | 443 | beforeEach(function () { 444 | setIntervalSpy = jasmine.createSpy('setInterval'); 445 | clearIntervalSpy = jasmine.createSpy('clearInterval'); 446 | spyOn(global, 'setInterval').and.callFake(setIntervalSpy); 447 | spyOn(global, 'clearInterval').and.callFake(clearIntervalSpy); 448 | }); 449 | 450 | afterEach(function () { 451 | setIntervalSpy.calls.reset(); 452 | clearIntervalSpy.calls.reset(); 453 | }); 454 | 455 | it('clears all timers and sends remaining metrics', function() { 456 | const sent = jasmine.createSpy('sent'); 457 | attachHook(sent); 458 | const scopedMetric = new cloudwatchMetric.Metric('namespace', 'Count', [{ 459 | Name: 'environment', 460 | Value: 'PROD' 461 | }], { 462 | sendInterval: 1000, 463 | summaryInterval: 1000, 464 | enabled: true 465 | }); 466 | 467 | expect(setIntervalSpy).toHaveBeenCalledTimes(2); 468 | 469 | scopedMetric.put(1, 'metricName', [{ Name:'ExtraDimension', Value: 'Value'}]); 470 | scopedMetric.summaryPut(10, 'summaryMetric', [{ Name: 'ExtraDimension', Value: 'Value'}]); 471 | 472 | expect(sent).not.toHaveBeenCalled(); 473 | 474 | scopedMetric.shutdown(); 475 | 476 | expect(sent).toHaveBeenCalledTimes(2); 477 | expect(clearIntervalSpy).toHaveBeenCalledTimes(2); 478 | }); 479 | }); 480 | }); 481 | -------------------------------------------------------------------------------- /spec/support/jasmine.json: -------------------------------------------------------------------------------- 1 | { 2 | "spec_dir": "spec", 3 | "spec_files": [ 4 | "**/*[sS]pec.js" 5 | ], 6 | "helpers": [ 7 | "helpers/**/*.js" 8 | ], 9 | "stopSpecOnExpectationFailure": false, 10 | "random": false 11 | } 12 | -------------------------------------------------------------------------------- /src/summarySet.js: -------------------------------------------------------------------------------- 1 | /** 2 | * A simple tool to track the aggregate of an unordered sequence of values. 3 | * 4 | * Produces MIN, MAX, SUM, and COUNT. 5 | */ 6 | class SummarySet { 7 | constructor({minSentinel = Infinity, maxSentinel = -Infinity} = {}) { 8 | this._minSentinel = minSentinel; 9 | this._maxSentinel = maxSentinel; 10 | 11 | this.reset(); 12 | } 13 | 14 | put(value) { 15 | this._sum += value; 16 | this._min = Math.min(this._min, value); 17 | this._max = Math.max(this._max, value); 18 | ++this._count; 19 | } 20 | 21 | get size() { 22 | return this._count; 23 | } 24 | 25 | /** 26 | * Get and reset the summarized statistics. 27 | * 28 | * @return {Object} The Minimum, Maximum, Sum, and SampleCount values. 29 | */ 30 | get() { 31 | const result = this.peek(); 32 | this.reset(); 33 | return result; 34 | } 35 | 36 | /** 37 | * Get the summarized statistics, but do not reset them. 38 | * 39 | * @return {Object} The Minimum, Maximum, Sum, and SampleCount values. 40 | */ 41 | peek() { 42 | return { 43 | Minimum: this._min, 44 | Maximum: this._max, 45 | Sum: this._sum, 46 | SampleCount: this._count, 47 | }; 48 | } 49 | 50 | /** 51 | * Reset the summarized statistics. 52 | */ 53 | reset() { 54 | this._min = this._minSentinel; 55 | this._max = this._maxSentinel; 56 | this._sum = 0; 57 | this._count = 0; 58 | } 59 | } 60 | 61 | module.exports = SummarySet; 62 | --------------------------------------------------------------------------------