Skip to content

Commit

Permalink
Merge pull request #1020 from Financial-Times/sass-build-time-monitoring
Browse files Browse the repository at this point in the history
Add sass build time monitoring [OR-483]
  • Loading branch information
joelcarr authored Jan 29, 2024
2 parents 62eab2b + 5f4b743 commit c7e4883
Show file tree
Hide file tree
Showing 4 changed files with 250 additions and 1 deletion.
3 changes: 3 additions & 0 deletions CODEOWNERS
Validating CODEOWNERS rules …
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
# See https://help.github.com/articles/about-codeowners/ for more information about this file.

* @financial-times/platforms

# The Origami team are responsible for sass-loader modifications.
packages/dotcom-build-sass/ @Financial-Times/origami-core
17 changes: 17 additions & 0 deletions packages/dotcom-build-sass/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,3 +71,20 @@ The CSS loader has `@import` and `url()` resolution disabled as these should be
| `webpackImporter` | Boolean | `false` | See https://github.com/webpack-contrib/sass-loader#webpackimporter |
| `prependData` | String | `''` | See https://webpack.js.org/loaders/sass-loader/#prependdata |
| `includePaths` | String[] | `[]` | See https://sass-lang.com/documentation/js-api#includepaths |

## Sass build monitoring

Sass build times are stored locally and remotely, where your project sets relevant API keys. Alternatively, you may turn both these features off using environment variable.

- Local reporting: A running total of your local Sass build times are stored in a temporary file on your machine. This statistic is reported periodically for your interest, along with a prompt to support FT efforts to move away from Sass.
- Alongside this, your local Sass build times are sent to the [biz-ops metrics api](https://github.com/Financial-Times/biz-ops-metrics-api), provided the below environment variables are set.


| Environment Variable | Required | Default | Description |
|--------------------------------------------|------------|------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `FT_SASS_STATS_NOTICE` | no | `throttle` | How often to log Sass statistics out to terminal. One of `throttle`, `never`, `always` |
| `FT_SASS_STATS_NOTICE_THROTTLE_SECONDS` | no | `1800` | How many seconds to wait between logging Sass statistics out to terminal. |
| `FT_SASS_STATS_NOTICE_THROTTLE_PERCENTAGE` | no | `30` | A percentage increase in total Sass build time in which to log out statistics to the terminal regardless of time. |
| `FT_SASS_STATS_MONITOR` | no | `off` | Set to `on` to send Sass build time statistics to [biz-ops metrics api](https://github.com/Financial-Times/biz-ops-metrics-api) Requires `FT_SASS_BIZ_OPS_API_KEY` and `FT_SASS_BIZ_OPS_SYSTEM_CODE`. |
| `FT_SASS_BIZ_OPS_API_KEY` | no | `` | A [Biz-Ops Metrics API Key](https://github.com/Financial-Times/biz-ops-metrics-api/blob/main/docs/API_DEFINITION.md#authentication) for your system. |
| `FT_SASS_BIZ_OPS_SYSTEM_CODE` | no | `` | The [biz-ops](https://biz-ops.in.ft.com/) system code of your project. Use `page-kit` if your system does not have a biz-ops code yet. |
2 changes: 1 addition & 1 deletion packages/dotcom-build-sass/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ export class PageKitSassPlugin {
// Enable use of Sass for CSS preprocessing
// https://github.com/webpack-contrib/sass-loader
{
loader: require.resolve('sass-loader'),
loader: require.resolve('./monitored-sass-loader'),
options: sassLoaderOptions
}
]
Expand Down
229 changes: 229 additions & 0 deletions packages/dotcom-build-sass/src/monitored-sass-loader.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,229 @@
import fs from 'fs'
import path from 'path'
import os from 'os'
import sassLoader from 'sass-loader'
import https from 'https'

const logError = (message) => {
// eslint-disable-next-line no-console
console.log(
`\n⛔️😭dotcom-build-sass: ${message}. Please report to #origami-support in Slack, so we can help move us away from Sass.\n`
)
}

class SassStats {
#monitorRemotely = process.env.FT_SASS_STATS_MONITOR === 'on'
#noticeStrategies = ['throttle', 'never', 'always']
#noticeStrategy = this.#noticeStrategies.includes(process.env.FT_SASS_STATS_NOTICE)
? process.env.FT_SASS_STATS_NOTICE
: 'throttle'
#noticeThrottleSeconds =
typeof process.env.FT_SASS_STATS_NOTICE_THROTTLE_SECONDS === 'number'
? process.env.FT_SASS_STATS_NOTICE_THROTTLE_SECONDS
: 60 * 60 * 0.5 // show throttled notice given 30 mins since last notice
#noticeThrottlePercentage =
typeof process.env.FT_SASS_STATS_NOTICE_THROTTLE_PERCENTAGE === 'number'
? process.env.FT_SASS_STATS_NOTICE_THROTTLE_PERCENTAGE
: 30 // show throttled notice given a 30% increase
#stats = { totalTime: 0, noticeDate: null, totalTimeAtLastNotice: 0 }
#directory = path.join(os.tmpdir(), 'dotcom-build-sass')
#file = path.join(this.#directory, 'sass-stats.json')
#startTime
#endTime

constructor() {
fs.mkdirSync(path.dirname(this.#directory), { recursive: true })
}

start = () => {
this.#read()
this.#startTime = performance.now()
}

end = () => {
this.#endTime = performance.now()
const updatedTotal = (this.#stats.totalTime += this.#endTime - this.#startTime)
this.#write({ totalTime: updatedTotal })
}

#read = () => {
try {
// Restore stats from a temporary file if it exists.
// Reading from disk ensures that we can track stats across builds.
const statsFile = fs.readFileSync(this.#file, 'utf-8')
this.#stats = JSON.parse(statsFile)
} catch {}
return this.#stats
}

#write = (stats) => {
this.#stats = Object.assign(this.#stats, stats)
fs.writeFileSync(this.#file, JSON.stringify(this.#stats))
}

sendMetric = () => {
if (!this.#monitorRemotely) {
return
}

if (!process.env.FT_SASS_BIZ_OPS_API_KEY) {
logError(
'We couldn\'t share your Sass build time, we\'re missing the environment variable "FT_SASS_BIZ_OPS_API_KEY". Please contact #origami-support with any questions.'
)
return
}
if (!process.env.FT_SASS_BIZ_OPS_SYSTEM_CODE) {
logError(
'We couldn\'t share your Sass build time, we\'re missing the environment variable "FT_SASS_BIZ_OPS_SYSTEM_CODE". Please contact #origami-support with any questions.'
)
return
}

const date = new Date()
const postData = JSON.stringify({
type: 'System',
metric: 'sass-build-time',
value: (this.#endTime - this.#startTime) / 1000,
date: date.toISOString(),
code: process.env.FT_SASS_BIZ_OPS_SYSTEM_CODE,
metadata: {
'node-env': process.env.NODE_ENV
}
})

const options = {
hostname: 'api.ft.com',
port: 443,
path: '/biz-ops-metrics/metric/add',
method: 'POST',
headers: {
'x-api-key': process.env.FT_SASS_BIZ_OPS_API_KEY,
'client-id': 'page-kit',
'Content-Type': 'application/json',
'Content-Length': postData.length
}
}

const request = https
.request(options, (response) => {
if (response.statusCode !== 200) {
logError(
`We couldn\'t send your Sass build time metrics to biz-ops. Status code: ${response.statusCode}.`
)
}
})
.on('error', (error) => {
logError(`We couldn\'t send your Sass build time metrics to biz-ops. Error: ${error}.`)
})
request.write(postData)
request.end()
}

reportAccordingToNoticeStrategy = () => {
let shouldReport

switch (this.#noticeStrategy) {
case 'never':
shouldReport = false
break

case 'always':
shouldReport = true
break

case 'throttle':
// Throttle notices to show a limited number per hour, or if the total sass build time
// has increased by a significant percentage. This favours more frequent reports to begin with.
const noticeTimeThrottle = Date.now() >= this.#stats.noticeDate + this.#noticeThrottleSeconds * 1000
const percentageTotalTimeThrottle =
this.#stats.totalTime > 0 &&
(this.#stats.totalTime / this.#stats.totalTimeAtLastNotice - 1) * 100 >=
this.#noticeThrottlePercentage // % increase
shouldReport = !this.#stats.noticeDate || noticeTimeThrottle || percentageTotalTimeThrottle
break

default:
break
}

if (shouldReport) {
this.#report()
}
}

#report = () => {
const seconds = this.#stats.totalTime / 1000
const minutes = seconds / 60
const hours = seconds / 3600
const time =
hours > 1
? `${hours.toFixed(1)} hours`
: minutes > 1
? `${minutes.toFixed(0)} minutes`
: `${seconds.toFixed(0)} seconds`
const emoji =
hours > 2 ? ['🔥', '😭', '😱'] : hours >= 1 ? ['🔥', '😱'] : minutes > 10 ? ['⏱️', '😬'] : ['⏱️']

let cta =
`Share your pain in Slack #sass-to-css, and help fix that! 🎉\n` +
`https://origami.ft.com/blog/2024/01/24/sass-build-times/\n\n`

if (!this.#monitorRemotely) {
cta =
`Help us improve build times by setting the "FT_SASS_STATS_MONITOR" environment variable.\n` +
`https://github.com/Financial-Times/biz-ops-metrics-api/blob/main/docs/API_DEFINITION.md#sass-build-monitoring \n\n`
}

// eslint-disable-next-line no-console
console.log(
`\n\ndotcom-build-sass:\nYou have spent at least ${emoji.join(' ')} ${time} ${emoji
.reverse()
.join(' ')} waiting on FT Sass to compile.\n${cta}`
)

this.#write({ noticeDate: Date.now(), totalTimeAtLastNotice: this.#stats.totalTime })
}
}

// We're proxying a few functions for monitoring purposes,
// we want to catch any monitoring errors silently.
const forgivingProxy = (target, task) => {
return new Proxy(target, {
apply(...args) {
try {
return task(...args)
} catch (error) {
Reflect.apply(...args)
logError(`Failed to monitor Sass build. Error: ${error}`)
}
}
})
}

const stats = new SassStats()
const monitoredSassLoaderProxy = forgivingProxy(sassLoader, (target, sassLoaderThis, argumentsList) => {
// Start the timer, sass-loader has been called with Sass content.
// https://github.com/webpack-contrib/sass-loader/blob/03773152760434a2dd845008c504a09c0eb3fd91/src/index.js#L19
stats.start()
// Assign our proxy to sass-loaders async function.
// https://github.com/webpack-contrib/sass-loader/blob/03773152760434a2dd845008c504a09c0eb3fd91/src/index.js#L29
const sassLoaderAsyncProxy = forgivingProxy(sassLoaderThis.async, (target, thisArg, argumentsList) => {
// Run sass-loader's async function as normal.
// Proxy the callback it returns.
// https://github.com/webpack-contrib/sass-loader/blob/03773152760434a2dd845008c504a09c0eb3fd91/src/index.js#L113
const sassLoaderCallback = Reflect.apply(target, thisArg, argumentsList)
return forgivingProxy(sassLoaderCallback, (target, thisArg, argumentsList) => {
// sass-loader's callback has been... called.
// Either we have sass, or the build failed.
stats.end()
stats.reportAccordingToNoticeStrategy()
stats.sendMetric()
return Reflect.apply(target, thisArg, argumentsList)
})
})
sassLoaderThis.async = sassLoaderAsyncProxy
// Run sass-loader as normal.
return Reflect.apply(target, sassLoaderThis, argumentsList)
})

export default monitoredSassLoaderProxy

0 comments on commit c7e4883

Please sign in to comment.