├── .gitignore ├── README.md ├── app.json ├── config.js ├── constants.js ├── index.js ├── lib ├── Alert.js ├── Analytics.js ├── Database.js ├── ErrorHandler.js ├── GitHub.js ├── Scheduler.js ├── SpeedTracker.js ├── alerts │ ├── Alert.Email.js │ └── Alert.SlackWebhook.js └── utils.js ├── package.json └── templates └── email.budget.js /.gitignore: -------------------------------------------------------------------------------- 1 | config.*.json 2 | node_modules 3 | result.json -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # API 4 | 5 | *SpeedTracker API* 6 | 7 | --- 8 | 9 | [![npm version](https://badge.fury.io/js/speedtracker-api.svg)](https://badge.fury.io/js/speedtracker-api) 10 | 11 | [![Deploy](https://www.herokucdn.com/deploy/button.svg)](https://heroku.com/deploy) 12 | 13 | --- 14 | 15 | ## Running your own API 16 | 17 | ### Pre-requisites 18 | 19 | First, you need to install a SpeedTracker dashboard by following steps 1 to 3 from [the SpeedTracker documentation page](https://speedtracker.org/docs). 20 | For configuration, follow the [*Configuration*](https://speedtracker.org/docs#configuration) and [*Configuration > Profiles*](https://speedtracker.org/docs#profiles) sections. 21 | 22 | You need to install [Node.js](https://nodejs.org/en/), `npm` and [MongoDB](https://www.mongodb.com/). 23 | 24 | ### Installation 25 | 26 | 1. Clone this repository in the location you want to run your API instance 27 | 28 | ``` 29 | git clone git@github.com:speedtracker/speedtracker-api.git 30 | cd speedtracker-api 31 | ``` 32 | 33 | 1. Run `npm install` 34 | 35 | 1. Create a [personal access token](https://help.github.com/articles/creating-an-access-token-for-command-line-use/) in your GitHub account under *Settings* > *Personal access tokens* 36 | - Be sure to check the option `public_repo`, since this is needed for the API to update and commit new performance data to your public GitHub version. 37 | 38 | 1. Create a file called `config.{ENVIRONMENT}.json` (e.g. `config.development.json`) with the following structure: 39 | 40 | ```js 41 | { 42 | // The port to run the API on (e.g. 8080) 43 | "port": 1234, 44 | 45 | // Your WebPageTest API key 46 | "wpt": { 47 | "key": "abcdefg" 48 | }, 49 | 50 | // Your GitHub personal access token 51 | "githubToken": "abcdefg", 52 | 53 | // MongoDB database connection URI 54 | "database": { 55 | "uri": "mongodb://localhost:27017/speedtracker" 56 | } 57 | } 58 | ``` 59 | 60 | 61 | ### Run 62 | 63 | The API instance is ready to run. Run `npm start` and, assuming you've followed the steps above on your local machine, you can access your API on `http://localhost:8080` (or whatever port you chose). 64 | 65 | If you see the `{"success":false,"error":"INVALID_URL_OR_METHOD"}` in your browser, the API is ready to be used. You'll see this message since we didn't supply the full URL the API needs to run a test. To do that, go to: 66 | 67 | ``` 68 | http://localhost:8080/v1/test/{USERNAME}/{REPOSITORY}/{BRANCH}/{PROFILE}?key={KEY} 69 | ``` 70 | 71 | And check [the documentation page](https://speedtracker.org/docs#run) for more information. 72 | 73 | --- 74 | 75 | ## License 76 | 77 | This project is licensed under the MIT license: 78 | 79 | The MIT License (MIT) 80 | 81 | Copyright (c) 2017 Eduardo Bouças 82 | 83 | Permission is hereby granted, free of charge, to any person obtaining a copy 84 | of this software and associated documentation files (the "Software"), to deal 85 | in the Software without restriction, including without limitation the rights 86 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 87 | copies of the Software, and to permit persons to whom the Software is 88 | furnished to do so, subject to the following conditions: 89 | 90 | The above copyright notice and this permission notice shall be included in all 91 | copies or substantial portions of the Software. 92 | 93 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 94 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 95 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 96 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 97 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 98 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 99 | SOFTWARE. 100 | -------------------------------------------------------------------------------- /app.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "SpeedTracker API", 3 | "description": "SpeedTracker API layer", 4 | "repository": "https://github.com/speedtracker/speedtracker-api", 5 | "logo": "https://speedtracker.org/assets/images/logo-square-inverted-transparent.png", 6 | "website": "https://speedtracker.org", 7 | "keywords": ["speedtracker", "webpagetest", "performance"], 8 | "env": { 9 | "NODE_ENV": { 10 | "description": "The applicaton environment", 11 | "value": "production" 12 | }, 13 | "WPT_KEY": { 14 | "description": "WPT API key" 15 | }, 16 | "WPT_URL": { 17 | "description": "WPT API URL", 18 | "value": "https://www.webpagetest.org" 19 | }, 20 | "GITHUB_TOKEN": { 21 | "description": "GitHub access token" 22 | }, 23 | "SPARKBOX_API_KEY": { 24 | "description": "Sparkbox API key" 25 | }, 26 | "MONGODB_URI": { 27 | "description": "Mongo database connection URI" 28 | }, 29 | "COLLECTION_REPOS": { 30 | "description": "Name of the collection to be used for storing repositories", 31 | "value": "st_repos" 32 | }, 33 | "SCHEDULING_CHECK_INTERVAL": { 34 | "description": "Interval at which the Scheduler checks for tests to run (in milliseconds)", 35 | "value": 900000 36 | }, 37 | "SCHEDULING_MIN_INTERVAL": { 38 | "description": "Minimum interval allowed for scheduled tests (in hours)", 39 | "value": 12 40 | } 41 | }, 42 | "addons": [ 43 | { 44 | "plan": "mongolab:sandbox", 45 | "as": "MONGODB" 46 | } 47 | ] 48 | } -------------------------------------------------------------------------------- /config.js: -------------------------------------------------------------------------------- 1 | var convict = require('convict') 2 | 3 | var conf = convict({ 4 | env: { 5 | doc: 'The applicaton environment', 6 | format: ['production', 'development', 'test'], 7 | default: 'development', 8 | env: 'NODE_ENV' 9 | }, 10 | port: { 11 | doc: 'The port to bind', 12 | format: 'port', 13 | default: 0, 14 | env: 'PORT' 15 | }, 16 | wpt: { 17 | key: { 18 | doc: 'WPT API key', 19 | format: String, 20 | default: null, 21 | env: 'WPT_KEY' 22 | }, 23 | url: { 24 | doc: 'WPT API URL', 25 | format: String, 26 | default: 'https://www.webpagetest.org', 27 | env: 'WPT_URL' 28 | } 29 | }, 30 | githubToken: { 31 | doc: 'GitHub access token', 32 | format: String, 33 | default: null, 34 | env: 'GITHUB_TOKEN' 35 | }, 36 | email: { 37 | sparkboxApiKey: { 38 | doc: 'Sparkbox API key', 39 | format: String, 40 | default: null, 41 | env: 'SPARKBOX_API_KEY' 42 | } 43 | }, 44 | database: { 45 | uri: { 46 | doc: 'Mongo database connection URI', 47 | format: String, 48 | default: null, 49 | env: 'MONGODB_URI' 50 | }, 51 | reposCollection: { 52 | doc: 'Name of the collection to be used for storing repositories', 53 | format: String, 54 | default: 'st_repos', 55 | env: 'COLLECTION_REPOS' 56 | } 57 | }, 58 | scheduling: { 59 | checkInterval: { 60 | doc: 'Interval at which the Scheduler checks for tests to run (in milliseconds)', 61 | format: Number, 62 | default: 900000, 63 | env: 'SCHEDULING_CHECK_INTERVAL' 64 | }, 65 | minimumInterval: { 66 | doc: 'Minimum interval allowed for scheduled tests (in hours)', 67 | format: Number, 68 | default: 12, 69 | env: 'SCHEDULING_MIN_INTERVAL' 70 | } 71 | }, 72 | analytics: { 73 | googleAnalyticsId: { 74 | doc: 'Google Analytics account ID', 75 | format: String, 76 | default: null, 77 | env: 'GOOGLE_ANALYTICS_ID' 78 | } 79 | }, 80 | raygunApiKey: { 81 | doc: 'Raygun API key', 82 | format: String, 83 | default: '', 84 | env: 'RAYGUN_APIKEY' 85 | }, 86 | blockList: { 87 | doc: 'Comma-separated list of GitHub usernames to block', 88 | format: String, 89 | default: '', 90 | env: 'BLOCK_LIST' 91 | }, 92 | pagespeedApiKey: { 93 | doc: 'Google PageSpeed Insights API key', 94 | format: String, 95 | default: '', 96 | env: 'PAGESPEED_API_KEY' 97 | } 98 | }) 99 | 100 | try { 101 | var env = conf.get('env') 102 | 103 | conf.loadFile(__dirname + '/config.' + env + '.json') 104 | conf.validate() 105 | 106 | console.log('(*) Local config file loaded') 107 | } catch (e) { 108 | 109 | } 110 | 111 | module.exports = conf 112 | -------------------------------------------------------------------------------- /constants.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | metrics: { 3 | breakdown: { 4 | html: { 5 | bytes: { 6 | name: 'HTML', 7 | transform: (value) => (value / 1000).toFixed(1), 8 | unit: 'KB' 9 | }, 10 | requests: { 11 | name: 'HTML' 12 | } 13 | }, 14 | js: { 15 | bytes: { 16 | name: 'JS', 17 | transform: (value) => (value / 1000).toFixed(1), 18 | unit: 'KB' 19 | }, 20 | requests: { 21 | name: 'JS' 22 | } 23 | }, 24 | css: { 25 | bytes: { 26 | name: 'CSS', 27 | transform: (value) => (value / 1000).toFixed(1), 28 | unit: 'KB' 29 | }, 30 | requests: { 31 | name: 'CSS' 32 | } 33 | }, 34 | image: { 35 | bytes: { 36 | name: 'Images', 37 | transform: (value) => (value / 1000).toFixed(1), 38 | unit: 'KB' 39 | }, 40 | requests: { 41 | name: 'Images' 42 | } 43 | }, 44 | flash: { 45 | bytes: { 46 | name: 'Flash', 47 | transform: (value) => (value / 1000).toFixed(1), 48 | unit: 'KB' 49 | }, 50 | requests: { 51 | name: 'Flash' 52 | } 53 | }, 54 | font: { 55 | bytes: { 56 | name: 'Fonts', 57 | transform: (value) => (value / 1000).toFixed(1), 58 | unit: 'KB' 59 | }, 60 | requests: { 61 | name: 'Fonts' 62 | } 63 | }, 64 | other: { 65 | bytes: { 66 | name: 'Other', 67 | transform: (value) => (value / 1000).toFixed(1), 68 | unit: 'KB' 69 | }, 70 | requests: { 71 | name: 'Other' 72 | } 73 | } 74 | }, 75 | loadTime: { 76 | name: 'Load time', 77 | transform: (value) => (value / 1000).toFixed(2), 78 | unit: 's', 79 | description: 'The time between the initial request and the browser load event' 80 | }, 81 | firstPaint: { 82 | name: 'Start render', 83 | transform: (value) => (value / 1000).toFixed(2), 84 | unit: 's', 85 | description: 'The time until the browser starts painting content to the screen' 86 | }, 87 | fullyLoaded: { 88 | name: 'Fully loaded', 89 | transform: (value) => (value / 1000).toFixed(2), 90 | unit: 's', 91 | description: 'The time at which the page has fully finished loading content' 92 | }, 93 | SpeedIndex: { 94 | name: 'SpeedIndex', 95 | transform: (value) => (value / 1000).toFixed(2), 96 | unit: 's', 97 | description: 'A custom metric introduced by WebPageTest to rate pages based on how quickly they are visually populated' 98 | }, 99 | TTFB: { 100 | name: 'Back-end', 101 | transform: (value) => (value / 1000).toFixed(2), 102 | unit: 's', 103 | description: 'The time it takes for the server to respond with the first byte of the response' 104 | }, 105 | visualComplete: { 106 | name: 'Visually complete', 107 | transform: (value) => (value / 1000).toFixed(2), 108 | unit: 's', 109 | description: 'The time it takes for the page to be fully visually populated' 110 | } 111 | } 112 | } -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const Analytics = require('./lib/Analytics') 4 | const config = require('./config') 5 | const cors = require('cors') 6 | const crypto = require('crypto') 7 | const Database = require('./lib/Database') 8 | const ErrorHandler = require('./lib/ErrorHandler') 9 | const express = require('express') 10 | const GitHub = require('./lib/GitHub') 11 | const Scheduler = require('./lib/Scheduler') 12 | const SpeedTracker = require('./lib/SpeedTracker') 13 | 14 | // ------------------------------------ 15 | // Server 16 | // ------------------------------------ 17 | 18 | const server = express() 19 | 20 | server.use(cors()) 21 | 22 | // ------------------------------------ 23 | // Scheduler 24 | // ------------------------------------ 25 | 26 | let scheduler 27 | 28 | // ------------------------------------ 29 | // GitHub 30 | // ------------------------------------ 31 | 32 | const github = new GitHub() 33 | 34 | github.authenticate(config.get('githubToken')) 35 | 36 | // ------------------------------------ 37 | // DB connection 38 | // ------------------------------------ 39 | 40 | let db = new Database(connection => { 41 | console.log('(*) Established database connection') 42 | 43 | server.listen(config.get('port'), () => { 44 | console.log(`(*) Server listening on port ${config.get('port')}`) 45 | }) 46 | 47 | scheduler = new Scheduler({ 48 | db: connection, 49 | remote: github 50 | }) 51 | }) 52 | 53 | // ------------------------------------ 54 | // Endpoint: Test 55 | // ------------------------------------ 56 | 57 | const testHandler = (req, res) => { 58 | const blockList = config.get('blockList').split(',') 59 | 60 | // Abort if user is blocked 61 | if (blockList.indexOf(req.params.user) !== -1) { 62 | ErrorHandler.log(`Request blocked for user ${req.params.user}`) 63 | 64 | return res.status(429).send() 65 | } 66 | 67 | const speedtracker = new SpeedTracker({ 68 | db, 69 | branch: req.params.branch, 70 | key: req.query.key, 71 | remote: github, 72 | repo: req.params.repo, 73 | scheduler, 74 | user: req.params.user 75 | }) 76 | 77 | let profileName = req.params.profile 78 | 79 | speedtracker.runTest(profileName).then(response => { 80 | res.send(JSON.stringify(response)) 81 | }).catch(err => { 82 | ErrorHandler.log(err) 83 | 84 | res.status(500).send(JSON.stringify(err)) 85 | }) 86 | } 87 | 88 | server.get('/v1/test/:user/:repo/:branch/:profile', testHandler) 89 | server.post('/v1/test/:user/:repo/:branch/:profile', testHandler) 90 | 91 | // ------------------------------------ 92 | // Endpoint: Connect 93 | // ------------------------------------ 94 | 95 | server.get('/v1/connect/:user/:repo', (req, res) => { 96 | const github = new GitHub(GitHub.GITHUB_CONNECT) 97 | 98 | github.authenticate(config.get('githubToken')) 99 | 100 | github.api.users.getRepoInvites({}).then(response => { 101 | let invitationId 102 | let invitation = response.some(invitation => { 103 | if (invitation.repository.full_name === (req.params.user + '/' + req.params.repo)) { 104 | invitationId = invitation.id 105 | 106 | return true 107 | } 108 | }) 109 | 110 | if (invitation) { 111 | return github.api.users.acceptRepoInvite({ 112 | id: invitationId 113 | }) 114 | } else { 115 | return Promise.reject() 116 | } 117 | }).then(response => { 118 | // Track event 119 | new Analytics().track(Analytics.Events.CONNECT) 120 | 121 | res.send('OK!') 122 | }).catch(err => { 123 | ErrorHandler.log(err) 124 | 125 | res.status(500).send('Invitation not found.') 126 | }) 127 | }) 128 | 129 | // ------------------------------------ 130 | // Endpoint: Encrypt 131 | // ------------------------------------ 132 | 133 | server.get('/encrypt/:key/:text?', (req, res) => { 134 | const key = req.params.key 135 | const text = req.params.text || req.params.key 136 | 137 | const cipher = crypto.createCipher('aes-256-ctr', key) 138 | let encrypted = cipher.update(decodeURIComponent(text), 'utf8', 'hex') 139 | 140 | encrypted += cipher.final('hex') 141 | 142 | res.send(encrypted) 143 | }) 144 | 145 | // ------------------------------------ 146 | // Endpoint: Decrypt 147 | // ------------------------------------ 148 | 149 | server.get('/decrypt/:key/:text?', (req, res) => { 150 | const decipher = crypto.createDecipher('aes-256-ctr', req.params.key) 151 | let decrypted = decipher.update(req.params.text, 'hex', 'utf8') 152 | 153 | decrypted += decipher.final('utf8') 154 | 155 | res.send(decrypted) 156 | }) 157 | 158 | // ------------------------------------ 159 | // Endpoint: Catch all 160 | // ------------------------------------ 161 | 162 | server.all('*', (req, res) => { 163 | const response = { 164 | success: false, 165 | error: 'INVALID_URL_OR_METHOD' 166 | } 167 | 168 | res.status(404).send(JSON.stringify(response)) 169 | }) 170 | 171 | // ------------------------------------ 172 | // Basic error logging 173 | // ------------------------------------ 174 | 175 | process.on('unhandledRejection', (reason, promise) => { 176 | if (reason) { 177 | ErrorHandler.log(reason) 178 | } 179 | }) 180 | -------------------------------------------------------------------------------- /lib/Alert.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const Email = require('./alerts/Alert.Email') 4 | const SlackWebhook = require('./alerts/Alert.SlackWebhook') 5 | const Utils = require('./utils') 6 | 7 | const Alert = function (data) { 8 | this.data = data 9 | 10 | switch (data.schema.type) { 11 | case 'email': 12 | this.handler = new Email(data) 13 | 14 | break 15 | 16 | case 'slack': 17 | this.handler = new SlackWebhook(data) 18 | 19 | break 20 | } 21 | } 22 | 23 | Alert.prototype.send = function (template, infractors) { 24 | return this.handler.send(template, infractors) 25 | } 26 | 27 | module.exports = Alert 28 | -------------------------------------------------------------------------------- /lib/Analytics.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const config = require(__dirname + '/../config') 4 | const UniversalAnalytics = require('universal-analytics') 5 | 6 | const events = { 7 | CONNECT: 'Repo connect', 8 | RUN_TEST: 'Run test' 9 | } 10 | 11 | const Analytics = function (id) { 12 | this.api = new UniversalAnalytics(config.get('analytics.googleAnalyticsId'), id) 13 | } 14 | 15 | Analytics.prototype.track = function (action, data) { 16 | let params = { 17 | ec: 'API', 18 | ea: action 19 | } 20 | 21 | if (data && data.label) { 22 | params.el = data.label 23 | } 24 | 25 | if (data && data.value) { 26 | params.ev = data.value 27 | } 28 | 29 | this.api.event(params).send() 30 | } 31 | 32 | module.exports = Analytics 33 | module.exports.Events = events 34 | -------------------------------------------------------------------------------- /lib/Database.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const config = require(__dirname + '/../config') 4 | const mongodb = require('mongodb') 5 | 6 | const Database = function (callback) { 7 | mongodb.MongoClient.connect(config.get('database.uri'), (err, connection) => { 8 | if (err) throw err 9 | 10 | // Store connection 11 | this.db = connection 12 | 13 | // Create schema 14 | this.createSchema(callback) 15 | }) 16 | } 17 | 18 | Database.prototype.createSchema = function (callback) { 19 | // Create `repos` collection 20 | this.db.createCollection(config.get('database.reposCollection'), (err, collection) => { 21 | if (err) throw err 22 | 23 | // Add index 24 | collection.createIndex({'repository': 1, 'profile': 1}, null, (err, results) => { 25 | if (err) throw err 26 | 27 | if (typeof callback === 'function') { 28 | callback(this.db) 29 | } 30 | }) 31 | }) 32 | } 33 | 34 | module.exports = Database 35 | -------------------------------------------------------------------------------- /lib/ErrorHandler.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const config = require(__dirname + '/../config') 4 | const Raygun = require('raygun') 5 | 6 | const ErrorHandler = function () { 7 | if (config.get('raygunApiKey').length) { 8 | this.client = new Raygun.Client().init({ 9 | apiKey: config.get('raygunApiKey') 10 | }) 11 | } 12 | } 13 | 14 | ErrorHandler.prototype.log = function (error) { 15 | if (this.client) { 16 | if (!(error instanceof Error)) { 17 | error = new Error(error) 18 | } 19 | 20 | this.client.send(error) 21 | } else { 22 | console.log(error) 23 | } 24 | } 25 | 26 | module.exports = (() => { 27 | return new ErrorHandler() 28 | })() 29 | -------------------------------------------------------------------------------- /lib/GitHub.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const GitHubApi = require('github') 4 | 5 | const GITHUB_CONNECT = 1 6 | 7 | const GitHub = function (type) { 8 | let headers = { 9 | 'user-agent': 'SpeedTracker agent' 10 | } 11 | 12 | switch (type) { 13 | case GITHUB_CONNECT: 14 | headers['Accept'] = 'application/vnd.github.swamp-thing-preview+json' 15 | 16 | break 17 | } 18 | 19 | this.api = new GitHubApi({ 20 | debug: (process.env.NODE_ENV !== 'production'), 21 | debug: false, 22 | protocol: 'https', 23 | host: 'api.github.com', 24 | pathPrefix: '', 25 | headers, 26 | timeout: 5000, 27 | Promise: Promise 28 | }) 29 | } 30 | 31 | GitHub.prototype.authenticate = function (token) { 32 | this.api.authenticate({ 33 | type: 'oauth', 34 | token: token 35 | }) 36 | 37 | return this 38 | } 39 | 40 | module.exports = GitHub 41 | 42 | module.exports.GITHUB_CONNECT = GITHUB_CONNECT 43 | -------------------------------------------------------------------------------- /lib/Scheduler.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const config = require(__dirname + '/../config') 4 | const ErrorHandler = require(__dirname + '/ErrorHandler') 5 | const SpeedTracker = require(__dirname + '/SpeedTracker') 6 | 7 | const Scheduler = function (options) { 8 | this.db = options.db 9 | this.remote = options.remote 10 | 11 | this.timer = setInterval(() => { 12 | this._checkTests() 13 | }, config.get('scheduling.checkInterval')) 14 | 15 | this._checkTests() 16 | } 17 | 18 | Scheduler.prototype._checkTests = function () { 19 | const currentTime = new Date().getTime() 20 | 21 | this.db.collection(config.get('database.reposCollection')).find({ 22 | nextRun: { 23 | $lte: currentTime 24 | } 25 | }).each((err, doc) => { 26 | if (doc) { 27 | const nwo = doc.repository.split('/') 28 | 29 | const speedtracker = new SpeedTracker({ 30 | db: this.db, 31 | branch: doc.branch, 32 | key: doc.key, 33 | remote: this.remote, 34 | repo: nwo[1], 35 | scheduler: this, 36 | user: nwo[0] 37 | }) 38 | 39 | speedtracker.runTest(doc.profile, true).catch(err => { 40 | //ErrorHandler.log(`Deleting failed scheduled test with id ${doc._id}...`) 41 | 42 | //this.delete(doc) 43 | }) 44 | } 45 | }) 46 | } 47 | 48 | Scheduler.prototype._getNextRun = function (profile) { 49 | const currentTime = new Date().getTime() 50 | const interval = Math.max(profile.interval, config.get('scheduling.minimumInterval')) 51 | const nextRun = currentTime + (interval * 3600000) 52 | 53 | return nextRun 54 | } 55 | 56 | Scheduler.prototype.delete = function (schedule) { 57 | return new Promise((resolve, reject) => { 58 | this.db.collection(config.get('database.reposCollection')).deleteOne({ 59 | _id: schedule._id 60 | }, (err, results) => { 61 | if (err) return reject(err) 62 | 63 | return resolve(null) 64 | }) 65 | }) 66 | } 67 | 68 | Scheduler.prototype.find = function (repository, profile) { 69 | return new Promise((resolve, reject) => { 70 | this.db.collection(config.get('database.reposCollection')).findOne({ 71 | profile, 72 | repository 73 | }, (err, document) => { 74 | if (err) return reject(err) 75 | 76 | return resolve(document) 77 | }) 78 | }) 79 | } 80 | 81 | Scheduler.prototype.insert = function (profile, branch, key) { 82 | const nextRun = this._getNextRun(profile) 83 | 84 | return new Promise((resolve, reject) => { 85 | this.db.collection(config.get('database.reposCollection')).insert({ 86 | branch, 87 | interval: profile.interval, 88 | key, 89 | nextRun, 90 | profile: profile._id, 91 | repository: profile._nwo, 92 | }, (err, documents) => { 93 | if (err) return reject(err) 94 | 95 | return resolve(nextRun) 96 | }) 97 | }) 98 | } 99 | 100 | Scheduler.prototype.update = function (profile, schedule) { 101 | const nextRun = this._getNextRun(profile) 102 | 103 | return new Promise((resolve, reject) => { 104 | this.db.collection(config.get('database.reposCollection')).update({ 105 | _id: schedule._id 106 | }, 107 | { 108 | $set: { 109 | interval: profile.interval, 110 | nextRun 111 | } 112 | }, (err, data) => { 113 | if (err) return reject(err) 114 | 115 | return resolve(nextRun) 116 | }) 117 | }) 118 | } 119 | 120 | module.exports = Scheduler 121 | -------------------------------------------------------------------------------- /lib/SpeedTracker.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const Alert = require('./Alert') 4 | const Analytics = require('./Analytics') 5 | const config = require(__dirname + '/../config') 6 | const Database = require('./Database') 7 | const fs = require('fs') 8 | const objectPath = require('object-path') 9 | const request = require('request-promise') 10 | const url = require('url') 11 | const Utils = require('./utils') 12 | const yaml = require('js-yaml') 13 | const yamlFront = require('yaml-front-matter') 14 | const WebPageTest = require('webpagetest') 15 | 16 | const SpeedTracker = function (options) { 17 | this.options = options 18 | } 19 | 20 | SpeedTracker.prototype._buildResult = function (data) { 21 | const pagespeed = data.pagespeed 22 | const wpt = data.wpt 23 | 24 | let result = { 25 | breakdown: {}, 26 | date: wpt.completed, 27 | domElements: wpt.average.firstView.domElements, 28 | domInteractive: wpt.average.firstView.domInteractive, 29 | firstPaint: wpt.average.firstView.firstPaint, 30 | fullyLoaded: wpt.average.firstView.fullyLoaded, 31 | id: wpt.id, 32 | loadTime: wpt.average.firstView.loadTime, 33 | render: wpt.average.firstView.render, 34 | SpeedIndex: wpt.average.firstView.SpeedIndex, 35 | TTFB: wpt.average.firstView.TTFB, 36 | videoFrames: wpt.runs[1].firstView.videoFrames.map(frame => { 37 | const frameUrl = url.parse(frame.image, true) 38 | 39 | return { 40 | _i: frameUrl.query.file, 41 | _t: frame.time, 42 | _vc: frame.VisuallyComplete 43 | } 44 | }), 45 | visualComplete: wpt.average.firstView.visualComplete 46 | } 47 | 48 | // Add Lighthouse score 49 | const lighthouseScore = wpt.average.firstView['lighthouse.ProgressiveWebApp'] 50 | 51 | result.lighthouse = typeof lighthouseScore !== 'undefined' 52 | ? Math.floor(lighthouseScore * 100) 53 | : null 54 | 55 | // Add content breakdown 56 | Object.keys(wpt.runs[1].firstView.breakdown).forEach((type) => { 57 | result.breakdown[type] = { 58 | bytes: wpt.runs[1].firstView.breakdown[type].bytes, 59 | requests: wpt.runs[1].firstView.breakdown[type].requests 60 | } 61 | }) 62 | 63 | // Add PageSpeed score 64 | result.pagespeed = pagespeed 65 | 66 | return Promise.resolve(result) 67 | } 68 | 69 | SpeedTracker.prototype._getPagespeedScore = function (url) { 70 | const apiKey = config.get('pagespeedApiKey') 71 | 72 | if (!apiKey.length) { 73 | return Promise.resolve(null) 74 | } 75 | 76 | const encodedUrl = encodeURIComponent(url) 77 | const pagespeedUrl = `https://www.googleapis.com/pagespeedonline/v2/runPagespeed?url=${encodedUrl}&key=${apiKey}` 78 | 79 | return request(pagespeedUrl).then(response => { 80 | try { 81 | const parsedResponse = JSON.parse(response) 82 | 83 | return parsedResponse.ruleGroups.SPEED.score 84 | } catch (err) { 85 | return Promise.resolve(null) 86 | } 87 | }).catch(err => { 88 | return Promise.resolve(null) 89 | }) 90 | } 91 | 92 | SpeedTracker.prototype._getRemoteFile = function (file) { 93 | return this.options.remote.api.repos.getContent({ 94 | user: this.options.user, 95 | repo: this.options.repo, 96 | path: file, 97 | ref: this.options.branch 98 | }).then(response => { 99 | var content = new Buffer(response.content, 'base64').toString() 100 | 101 | return { 102 | content, 103 | sha: response.sha 104 | } 105 | }) 106 | } 107 | 108 | SpeedTracker.prototype._processBudgets = function (profileData, result) { 109 | if (!profileData.budgets || !(profileData.budgets instanceof Array)) { 110 | return Promise.resolve(true) 111 | } 112 | 113 | let infractorsByAlert = {} 114 | 115 | profileData.budgets.forEach(budget => { 116 | let value = objectPath.get(result, budget.metric) 117 | 118 | if (typeof value !== 'undefined') { 119 | let infractionType 120 | 121 | if ((typeof budget.max !== 'undefined') && value > budget.max) { 122 | infractionType = 'max' 123 | } else if ((typeof budget.min !== 'undefined') && value < budget.min) { 124 | infractionType = 'min' 125 | } 126 | 127 | if (infractionType && (budget.alerts instanceof Array)) { 128 | budget.alerts.forEach(alertName => { 129 | infractorsByAlert[alertName] = infractorsByAlert[alertName] || [] 130 | 131 | infractorsByAlert[alertName].push({ 132 | limit: budget[infractionType], 133 | metric: budget.metric, 134 | value 135 | }) 136 | }) 137 | } 138 | } 139 | }) 140 | 141 | // Send alerts 142 | Object.keys(infractorsByAlert).forEach(alertName => { 143 | this._sendAlert('budget', alertName, infractorsByAlert[alertName], { 144 | profile: profileData, 145 | result 146 | }) 147 | }) 148 | } 149 | 150 | SpeedTracker.prototype._processSchedule = function (profile, schedule) { 151 | const currentTime = new Date().getTime() 152 | 153 | if (schedule) { 154 | // Interval has been removed from profile, needs to be removed from database 155 | if (!profile.interval) { 156 | return this.options.scheduler.delete(schedule) 157 | } 158 | 159 | // Either the test has run at its time or the interval has been updated on the 160 | // profile and needs to be updated on the database 161 | if ((currentTime >= schedule.nextRun) || (profile.interval !== schedule.interval)) { 162 | return this.options.scheduler.update(profile, schedule) 163 | } 164 | 165 | return Promise.resolve(schedule.nextRun) 166 | } else if (profile.interval) { 167 | return this.options.scheduler.insert(profile, this.options.branch, this.options.key) 168 | } 169 | 170 | return Promise.resolve(null) 171 | } 172 | 173 | SpeedTracker.prototype._runWptTest = function (url, parameters, callback) { 174 | return new Promise((resolve, reject) => { 175 | const wptResult = this.wpt.runTest(url, parameters, (err, response) => { 176 | if (err) return reject(err) 177 | 178 | if (!response.statusCode || (response.statusCode !== 200)) { 179 | return reject(response) 180 | } 181 | 182 | const interval = setInterval(() => { 183 | this.wpt.getTestResults(response.data.testId, (err, results) => { 184 | // Check for errors 185 | if (err || (results.statusCode >= 300)) { 186 | return clearInterval(interval) 187 | } 188 | 189 | // Check for completion 190 | if ((results.statusCode >= 200) && (results.statusCode < 300)) { 191 | clearInterval(interval) 192 | 193 | return callback(results) 194 | } 195 | }) 196 | }, 15000) 197 | 198 | return resolve(response) 199 | }) 200 | }) 201 | } 202 | 203 | SpeedTracker.prototype._saveTest = function (profile, content, isScheduled) { 204 | const date = new Date(content.date * 1000) 205 | const year = date.getFullYear() 206 | const month = Utils.padWithZeros(date.getMonth() + 1, 2) 207 | const day = Utils.padWithZeros(date.getDate(), 2) 208 | 209 | const path = `results/${profile}/${year}/${month}.json` 210 | 211 | const message = `Add SpeedTracker test (${isScheduled ? 'scheduled' : 'manual'})` 212 | 213 | return this._getRemoteFile(path).then(data => { 214 | try { 215 | let payload = JSON.parse(data.content) 216 | 217 | // Append timestamp 218 | payload._ts.push(content.date) 219 | 220 | // Append results 221 | Utils.mergeObject(payload._r, content, payload._ts.length) 222 | 223 | return this.options.remote.api.repos.updateFile({ 224 | user: this.options.user, 225 | repo: this.options.repo, 226 | branch: this.options.branch, 227 | path: path, 228 | sha: data.sha, 229 | content: new Buffer(JSON.stringify(payload)).toString('base64'), 230 | message: message 231 | }) 232 | } catch (err) { 233 | return Promise.reject(Utils.buildError('CORRUPT_RESULT_FILE')) 234 | } 235 | }).catch(err => { 236 | if (err.code === 404) { 237 | let payload = { 238 | _ts: [content.date], 239 | _r: {} 240 | } 241 | 242 | // Append results 243 | Utils.mergeObject(payload._r, content) 244 | 245 | return this.options.remote.api.repos.createFile({ 246 | user: this.options.user, 247 | repo: this.options.repo, 248 | branch: this.options.branch, 249 | path: path, 250 | content: new Buffer(JSON.stringify(payload)).toString('base64'), 251 | message: message 252 | }) 253 | } else { 254 | return Promise.reject(Utils.buildError('CORRUPT_RESULT_FILE')) 255 | } 256 | }) 257 | } 258 | 259 | SpeedTracker.prototype._sendAlert = function (type, name, infractors, data) { 260 | const schema = this.config.alerts[name] 261 | 262 | if (!schema) return 263 | 264 | const alert = new Alert({ 265 | schema, 266 | config: this.config, 267 | profile: data.profile, 268 | result: data.result 269 | }) 270 | 271 | return alert.send(type, infractors) 272 | } 273 | 274 | SpeedTracker.prototype.getConfig = function (force) { 275 | if (this.config && !force) { 276 | return Promise.resolve(this.config) 277 | } 278 | 279 | return this._getRemoteFile('speedtracker.yml').then(data => { 280 | try { 281 | var configFile = yaml.safeLoad(data.content, 'utf8') 282 | 283 | this.config = configFile 284 | 285 | // Inject site URL 286 | this.config._url = `http://${this.options.user}.github.io/${this.options.repo}` 287 | 288 | return configFile 289 | } catch (err) { 290 | return Promise.reject(Utils.buildError('INVALID_CONFIG')) 291 | } 292 | }).catch(err => { 293 | return Promise.reject(Utils.buildError('INVALID_CONFIG')) 294 | }) 295 | } 296 | 297 | SpeedTracker.prototype.getProfile = function (profile) { 298 | let path = `_profiles/${profile}.html` 299 | 300 | return this._getRemoteFile(path).then(data => { 301 | let parsedFront = yamlFront.loadFront(data.content) 302 | 303 | // Delete body 304 | delete parsedFront.__content 305 | 306 | return parsedFront 307 | }) 308 | } 309 | 310 | SpeedTracker.prototype.initConfig = function () { 311 | return this.getConfig().then(config => { 312 | if (config.encryptionKey && (this.options.key === Utils.decrypt(config.encryptionKey, this.options.key))) { 313 | this.config._encryptionKey = this.options.key 314 | 315 | return this.config 316 | } 317 | 318 | return Promise.reject(Utils.buildError('AUTH_FAILED')) 319 | }) 320 | } 321 | 322 | SpeedTracker.prototype.initWpt = function (profile) { 323 | let wptUrl = this.config.wptUrl ? Utils.decrypt(this.config.wptUrl, this.options.key) : config.get('wpt.url') 324 | let wptKey = this.config.wptKey ? Utils.decrypt(this.config.wptKey, this.options.key) : config.get('wpt.key') 325 | 326 | // If a wptUrl is defined at a profile level, it overrides the instance and 327 | // site configs. 328 | if (profile.wptUrl) { 329 | wptUrl = profile.wptUrl.startsWith('http') 330 | ? profile.wptUrl 331 | : Utils.decrypt(profile.wptUrl, this.options.key) 332 | } 333 | 334 | this.wpt = new WebPageTest(wptUrl, wptKey) 335 | this.wptUrl = wptUrl 336 | } 337 | 338 | SpeedTracker.prototype.runTest = function (profile, isScheduled) { 339 | let defaults = { 340 | connectivity: 'Cable', 341 | lighthouse: true, 342 | firstViewOnly: true, 343 | runs: 1, 344 | } 345 | 346 | let overrides = { 347 | video: true 348 | } 349 | 350 | return this.initConfig().then(() => { 351 | return this.getProfile(profile) 352 | }).then(profile => { 353 | this.initWpt(profile) 354 | 355 | return Promise.resolve(profile) 356 | }).then(profileData => { 357 | // Inject profile name 358 | profileData._id = profile 359 | 360 | // Inject GitHub NWO 361 | profileData._nwo = `${this.options.user}/${this.options.repo}` 362 | 363 | let parameters = Object.assign({}, defaults, profileData.parameters, overrides) 364 | 365 | if (!parameters.url) return Promise.reject('NO_URL') 366 | 367 | let url = parameters.url.trim() 368 | 369 | if (!url.startsWith('http')) { 370 | url = Utils.decrypt(parameters.url, this.options.key) 371 | } 372 | 373 | delete parameters.url 374 | 375 | return this.options.scheduler.find(profileData._nwo, profileData._id).then(schedule => { 376 | const runTest = !isScheduled || (profileData.interval && (profileData.interval === schedule.interval)) 377 | 378 | let testJob = Promise.resolve({}) 379 | 380 | if (runTest) { 381 | testJob = this._runWptTest(url, parameters, wpt => { 382 | return this._getPagespeedScore(url).then(score => { 383 | return this._buildResult({ 384 | pagespeed: score, 385 | wpt: wpt.data 386 | }).then(result => { 387 | // Save test 388 | this._saveTest(profile, result, isScheduled) 389 | 390 | // Process budgets 391 | this._processBudgets(profileData, result) 392 | }) 393 | }) 394 | }) 395 | 396 | // Track event 397 | const userId = schedule && schedule._id 398 | const analytics = new Analytics(userId) 399 | 400 | analytics.track(Analytics.Events.RUN_TEST, { 401 | label: schedule ? 'Scheduled' : 'Manual' 402 | }) 403 | } 404 | 405 | return testJob.then(testResult => { 406 | return this._processSchedule(profileData, schedule).then(nextRun => { 407 | let response = { 408 | success: runTest, 409 | nextRun 410 | } 411 | 412 | if (testResult.data && testResult.data.testId) { 413 | response.testId = testResult.data.testId 414 | } 415 | 416 | return response 417 | }) 418 | }).catch(err => { 419 | return Promise.reject(Utils.buildError('WPT_ERROR', err.statusText)) 420 | }) 421 | }) 422 | }) 423 | } 424 | 425 | module.exports = SpeedTracker 426 | -------------------------------------------------------------------------------- /lib/alerts/Alert.Email.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const config = require(__dirname + '/../../config') 4 | const SparkPost = require('sparkpost') 5 | const utils = require(__dirname + '/../utils') 6 | 7 | const Email = function (data) { 8 | this.api = new SparkPost(config.get('email.sparkboxApiKey')) 9 | this.data = data 10 | 11 | this.templates = { 12 | budget: require(__dirname + '/../../templates/email.budget.js') 13 | } 14 | } 15 | 16 | Email.prototype.send = function (templateName, infractors) { 17 | const recipients = this.data.schema.recipients.map(recipient => { 18 | return utils.decrypt(recipient, this.data.config._encryptionKey) 19 | }) 20 | 21 | const template = this.templates[templateName] 22 | const email = template(infractors, this.data) 23 | 24 | return new Promise((resolve, reject) => { 25 | this.api.transmissions.send({ 26 | transmissionBody: { 27 | content: { 28 | from: email.sender, 29 | subject: email.subject, 30 | html: email.body 31 | }, 32 | recipients: recipients.map(recipient => { 33 | return {address: recipient} 34 | }) 35 | } 36 | }, (err, res) => { 37 | if (err) return reject(err) 38 | 39 | return resolve(res) 40 | }) 41 | }) 42 | } 43 | 44 | module.exports = Email 45 | -------------------------------------------------------------------------------- /lib/alerts/Alert.SlackWebhook.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const constants = require(__dirname + '/../../constants') 4 | const config = require(__dirname + '/../../config') 5 | const objectPath = require('object-path') 6 | const SlackWebhookAPI = require('@slack/client').IncomingWebhook 7 | const utils = require(__dirname + '/../utils') 8 | 9 | const SlackWebhook = function (data) { 10 | this.data = data 11 | } 12 | 13 | SlackWebhook.prototype._getBudgetTemplate = function (infractors) { 14 | const payload = { 15 | text: `The latest performance report on *${this.data.profile.name}* showed some performance metrics going over their budget:`, 16 | attachments: [ 17 | { 18 | fallback: 'SpeedTracker performance report', 19 | color: 'warning', 20 | title: 'SpeedTracker - View report', 21 | title_link: this.data.config._url || 'https://speedtracker.org', 22 | fields: infractors.map(infractor => { 23 | const comparisonSign = infractor.value > infractor.limit ? '>' : '<' 24 | const metric = objectPath.get(constants.metrics, infractor.metric) 25 | const title = (metric && metric.name) || infractor.metric 26 | 27 | return { 28 | title, 29 | value: `${utils.formatMetric(infractor.metric, infractor.value)} (${comparisonSign} ${utils.formatMetric(infractor.metric, infractor.limit)})`, 30 | short: 'false' 31 | } 32 | }), 33 | footer: 'SpeedTracker', 34 | footer_icon: 'https://speedtracker.org/assets/images/logo-square-inverted-128.png' 35 | } 36 | ] 37 | } 38 | 39 | return payload 40 | } 41 | 42 | SlackWebhook.prototype.send = function (template, infractors) { 43 | if (!this.data.schema.hookUrl) return 44 | 45 | const url = utils.decrypt(this.data.schema.hookUrl, this.data.config._encryptionKey) 46 | 47 | const api = new SlackWebhookAPI(url) 48 | 49 | let payload 50 | 51 | switch (template) { 52 | case 'budget': 53 | payload = this._getBudgetTemplate(infractors) 54 | 55 | break 56 | } 57 | 58 | if (this.data.schema.channel) { 59 | payload.channel = this.data.schema.channel 60 | } 61 | 62 | if (this.data.schema.username) { 63 | payload.username = this.data.schema.username 64 | } 65 | 66 | if (this.data.schema.iconEmoji) { 67 | payload.iconEmoji = this.data.schema.iconEmoji 68 | } 69 | 70 | api.send(payload) 71 | } 72 | 73 | module.exports = SlackWebhook 74 | -------------------------------------------------------------------------------- /lib/utils.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const constants = require(__dirname + '/../constants') 4 | const crypto = require('crypto') 5 | const objectPath = require('object-path') 6 | 7 | module.exports.buildError = (code, message) => { 8 | let errorObject = { 9 | success: false, 10 | error: { 11 | code 12 | } 13 | } 14 | 15 | if (message) { 16 | errorObject.error.message = message 17 | } 18 | 19 | return errorObject 20 | } 21 | 22 | module.exports.decrypt = (passphrase, key) => { 23 | let decipher = crypto.createDecipher('aes-256-ctr', key) 24 | let decrypted = decipher.update(passphrase, 'hex', 'utf8') 25 | 26 | decrypted += decipher.final('utf8') 27 | 28 | return decrypted 29 | } 30 | 31 | module.exports.formatMetric = (metricName, value) => { 32 | const metric = objectPath.get(constants.metrics, metricName) 33 | 34 | if (!metric) return 35 | 36 | let output = value 37 | 38 | if (metric.transform) { 39 | output = metric.transform(output) 40 | } 41 | 42 | if (metric.unit) { 43 | output += metric.unit 44 | } 45 | 46 | return output 47 | } 48 | 49 | module.exports.padWithZeros = (input, length) => { 50 | let inputStr = input.toString() 51 | let lengthDiff = length - inputStr.length 52 | 53 | if (lengthDiff > 0) { 54 | return '0'.repeat(lengthDiff) + inputStr 55 | } 56 | 57 | return inputStr 58 | } 59 | 60 | const traverseObject = (obj, callback, path) => { 61 | path = path || [] 62 | 63 | if ((typeof obj === 'object') && !(obj instanceof Array) && (obj !== null)) { 64 | Object.keys(obj).forEach(key => { 65 | traverseObject(obj[key], callback, path.concat(key)) 66 | }) 67 | } else { 68 | callback(obj, path) 69 | } 70 | } 71 | 72 | module.exports.traverseObject = traverseObject 73 | 74 | module.exports.mergeObject = (base, newObj, length) => { 75 | traverseObject(newObj, (obj, path) => { 76 | let joinedPath = path.join('.') 77 | let baseValue = objectPath.get(base, joinedPath) 78 | 79 | if (typeof baseValue === 'undefined') { 80 | const emptyArray = Array.apply(null, {length: length - 1}).map(() => null) 81 | 82 | objectPath.set(base, joinedPath, emptyArray) 83 | 84 | baseValue = objectPath.get(base, joinedPath) 85 | } 86 | 87 | baseValue.push(obj) 88 | }) 89 | } 90 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "speedtracker-api", 3 | "version": "1.0.2", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1", 8 | "start": "node index.js" 9 | }, 10 | "author": "Eduardo Boucas (https://eduardoboucas.com/)", 11 | "license": "ISC", 12 | "dependencies": { 13 | "@slack/client": "^3.6.0", 14 | "body-parser": "^1.15.2", 15 | "convict": "^1.4.0", 16 | "cors": "^2.8.1", 17 | "crypto": "0.0.3", 18 | "express": "^4.14.0", 19 | "github": "^3.0.0", 20 | "js-yaml": "^3.6.1", 21 | "mailgun-js": "^0.7.12", 22 | "mongodb": "^2.2.10", 23 | "object-path": "^0.11.2", 24 | "raygun": "^0.9.0", 25 | "request-promise": "^4.2.0", 26 | "sparkpost": "^1.3.8", 27 | "universal-analytics": "^0.4.6", 28 | "webpagetest": "github:eduardoboucas/webpagetest-api", 29 | "yaml-front-matter": "^3.4.0" 30 | }, 31 | "devDependencies": {} 32 | } 33 | -------------------------------------------------------------------------------- /templates/email.budget.js: -------------------------------------------------------------------------------- 1 | const constants = require(__dirname + '/../constants') 2 | const objectPath = require('object-path') 3 | const utils = require(__dirname + '/../lib/utils') 4 | 5 | const email = (infractors, data) => { 6 | const body = ` 7 | Hello,
8 |
9 | The latest performance report on ${data.profile.name} showed some performance metrics going over their configured budgets:
10 |
11 | 19 |
20 | Click here to see the full report.
21 |
22 | --- 23 |
24 | SpeedTracker 25 | ` 26 | 27 | return { 28 | body, 29 | sender: 'SpeedTracker ', 30 | subject: `Performance report for ${data.profile.name}` 31 | } 32 | } 33 | 34 | module.exports = email 35 | --------------------------------------------------------------------------------