├── .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 | [](https://badge.fury.io/js/speedtracker-api)
10 |
11 | [](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 |
12 | ${infractors.map(infractor => {
13 | const comparisonSign = infractor.value > infractor.limit ? '>' : '<'
14 | const metric = objectPath.get(constants.metrics, infractor.metric)
15 |
16 | return `- ${metric.name}: ${utils.formatMetric(infractor.metric, infractor.value)} (${comparisonSign} ${utils.formatMetric(infractor.metric, infractor.limit)})`
17 | }).join('')}
18 |
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 |
--------------------------------------------------------------------------------