Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add kvWriteCooldownMinutes to redues KV write operations #147

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,9 @@ The Workers Free plan includes limited KV usage, but the quota is sufficient for

- Change the CRON trigger to 2 minutes interval (`crons = ["*/2 * * * *"]`) in [wrangler.toml](./wrangler.toml)

Or you can increase the `kvWriteCooldownMinutes` to hugely reduce the number of writes to KV, however, it will also reduce the accuracy of the status page.


## Known issues

- **Max 25 monitors to watch in case you are using Slack notifications**, due to the limit of subrequests Cloudflare Worker can make \(50\).
Expand Down
2 changes: 2 additions & 0 deletions config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ settings:
logo: logo-192x192.png # image in ./public/ folder
daysInHistogram: 90 # number of days you want to display in histogram
collectResponseTimes: true # collects avg response times from CRON locations
# Min time between KV writes, unless status changes. Helps stay within free tier limits. Default: 60 minutes.
kvWriteCooldownMinutes: 60

allmonitorsOperational: 'All Systems Operational'
notAllmonitorsOperational: 'Not All Systems Operational'
Expand Down
2 changes: 1 addition & 1 deletion src/components/monitorStatusHeader.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ export default function MonitorStatusHeader({ kvMonitorsLastUpdate }) {
<div>{text}</div>
{kvMonitorsLastUpdate.time && typeof window !== 'undefined' && (
<div className="text-xs font-light">
checked{' '}
updated{' '}
{Math.round((Date.now() - kvMonitorsLastUpdate.time) / 1000)} sec
ago (from{' '}
{locations[kvMonitorsLastUpdate.loc] || kvMonitorsLastUpdate.loc})
Expand Down
255 changes: 135 additions & 120 deletions src/functions/cronTrigger.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import config from '../../config.yaml'

import {
notifySlack,
notifyTelegram,
Expand All @@ -13,147 +12,163 @@ function getDate() {
return new Date().toISOString().split('T')[0]
}

export async function processCronTrigger(event) {
// Get Worker PoP and save it to monitorsStateMetadata
const checkLocation = await getCheckLocation()
const checkDay = getDate()

// Get monitors state from KV
let monitorsState = await getKVMonitors()

// Create empty state objects if not exists in KV storage yet
if (!monitorsState) {
monitorsState = { lastUpdate: {}, monitors: {} }
async function checkMonitorStatus(monitor, init) {
const requestStartTime = Date.now()
const checkResponse = await fetch(monitor.url, init)
const requestTime = Math.round(Date.now() - requestStartTime)
const operational = checkResponse.status === (monitor.expectStatus || 200)
return {
operational,
requestTime,
status: checkResponse.status,
statusText: checkResponse.statusText,
}
}

// Reset default all monitors state to true
monitorsState.lastUpdate.allOperational = true

for (const monitor of config.monitors) {
// Create default monitor state if does not exist yet
if (typeof monitorsState.monitors[monitor.id] === 'undefined') {
monitorsState.monitors[monitor.id] = {
firstCheck: checkDay,
lastCheck: {},
checks: {},
}
}

console.log(`Checking ${monitor.name} ...`)

// Fetch the monitors URL
const init = {
method: monitor.method || 'GET',
redirect: monitor.followRedirect ? 'follow' : 'manual',
headers: {
'User-Agent': config.settings.user_agent || 'cf-worker-status-page',
},
}

// Perform a check and measure time
const requestStartTime = Date.now()
const checkResponse = await fetch(monitor.url, init)
const requestTime = Math.round(Date.now() - requestStartTime)

// Determine whether operational and status changed
const monitorOperational =
checkResponse.status === (monitor.expectStatus || 200)
const monitorStatusChanged =
monitorsState.monitors[monitor.id].lastCheck.operational !==
monitorOperational

// Save monitor's last check response status
monitorsState.monitors[monitor.id].lastCheck = {
status: checkResponse.status,
statusText: checkResponse.statusText,
operational: monitorOperational,
}

// Send Slack message on monitor change
if (
monitorStatusChanged &&
async function sendNotifications(
event,
monitor,
monitorOperational,
monitorStatusChanged,
) {
if (monitorStatusChanged) {
const shouldNotifySlack =
typeof SECRET_SLACK_WEBHOOK_URL !== 'undefined' &&
SECRET_SLACK_WEBHOOK_URL !== 'default-gh-action-secret'
) {
if (shouldNotifySlack) {
event.waitUntil(notifySlack(monitor, monitorOperational))
}

// Send Telegram message on monitor change
if (
monitorStatusChanged &&
const shouldNotifyTelegram =
typeof SECRET_TELEGRAM_API_TOKEN !== 'undefined' &&
SECRET_TELEGRAM_API_TOKEN !== 'default-gh-action-secret' &&
typeof SECRET_TELEGRAM_CHAT_ID !== 'undefined' &&
SECRET_TELEGRAM_CHAT_ID !== 'default-gh-action-secret'
) {
SECRET_TELEGRAM_API_TOKEN !== 'default-gh-action-secret'
if (shouldNotifyTelegram) {
event.waitUntil(notifyTelegram(monitor, monitorOperational))
}

// Send Discord message on monitor change
if (
monitorStatusChanged &&
const shouldNotifyDiscord =
typeof SECRET_DISCORD_WEBHOOK_URL !== 'undefined' &&
SECRET_DISCORD_WEBHOOK_URL !== 'default-gh-action-secret'
) {
if (shouldNotifyDiscord) {
event.waitUntil(notifyDiscord(monitor, monitorOperational))
}
}
}

function updateMonitorState(
monitorsState,
monitor,
operational,
status,
statusText,
checkDay,
requestTime,
checkLocation,
) {
let stateChanged = false
const monitorState = monitorsState.monitors[monitor.id]
if (monitorState.lastCheck.operational !== operational) {
monitorState.lastCheck = { status, statusText, operational }
stateChanged = true
}
if (config.settings.collectResponseTimes && operational) {
if (!monitorState.checks[checkDay]) {
monitorState.checks[checkDay] = { fails: 0, res: {} }
stateChanged = true
}
if (!monitorState.checks[checkDay].res[checkLocation]) {
monitorState.checks[checkDay].res[checkLocation] = { n: 0, ms: 0, a: 0 }
}
const locData = monitorState.checks[checkDay].res[checkLocation]
locData.n++
locData.ms += requestTime
locData.a = Math.round(locData.ms / locData.n)
// stateChanged = true
} else if (!operational) {
if (!monitorState.checks[checkDay]) {
monitorState.checks[checkDay] = { fails: 1, res: {} }
stateChanged = true
} else if (monitorState.checks[checkDay].fails === 0 || stateChanged) {
monitorState.checks[checkDay].fails++
stateChanged = true
}
}

// make sure checkDay exists in checks in cases when needed
if (
(config.settings.collectResponseTimes || !monitorOperational) &&
!monitorsState.monitors[monitor.id].checks.hasOwnProperty(checkDay)
) {
monitorsState.monitors[monitor.id].checks[checkDay] = {
fails: 0,
res: {},
}
// Returning the possibly updated monitorsState
return { updated: stateChanged, monitorsState }
}

export async function processCronTrigger(event) {
const checkLocation = await getCheckLocation()
const checkDay = getDate()
let monitorsState = (await getKVMonitors()) || {
lastUpdate: { time: 0 },
monitors: {},
}
let isUpdateRequired = false

const now = Date.now()
const cooldownMinutes = (config.settings.kvWriteCooldownMinutes || 60) * 60000
if (now - monitorsState.lastUpdate.time > cooldownMinutes) {
isUpdateRequired = true
}

for (const monitor of config.monitors) {
const init = {
method: monitor.method || 'GET',
headers: {
'User-Agent': config.settings.user_agent || 'cf-worker-status-page',
},
redirect: monitor.followRedirect ? 'follow' : 'manual',
}
const {
operational,
requestTime,
status,
statusText,
} = await checkMonitorStatus(monitor, init)
const monitorOperational = operational // This was not defined previously, assuming it's the result of the operational check.
const monitorStatusChanged =
monitorsState.monitors[monitor.id]?.lastCheck?.operational !==
monitorOperational

if (config.settings.collectResponseTimes && monitorOperational) {
// make sure location exists in current checkDay
if (
!monitorsState.monitors[monitor.id].checks[checkDay].res.hasOwnProperty(
checkLocation,
)
) {
monitorsState.monitors[monitor.id].checks[checkDay].res[
checkLocation
] = {
n: 0,
ms: 0,
a: 0,
}
if (!monitorsState.monitors[monitor.id]) {
monitorsState.monitors[monitor.id] = {
firstCheck: checkDay,
lastCheck: {},
checks: {},
}
isUpdateRequired = true
}

// increment number of checks and sum of ms
const no = ++monitorsState.monitors[monitor.id].checks[checkDay].res[
checkLocation
].n
const ms = (monitorsState.monitors[monitor.id].checks[checkDay].res[
checkLocation
].ms += requestTime)

// save new average ms
monitorsState.monitors[monitor.id].checks[checkDay].res[
checkLocation
].a = Math.round(ms / no)
} else if (!monitorOperational) {
// Save allOperational to false
monitorsState.lastUpdate.allOperational = false

// Increment failed checks on status change or first fail of the day (maybe call it .incidents instead?)
if (monitorStatusChanged || monitorsState.monitors[monitor.id].checks[checkDay].fails == 0) {
monitorsState.monitors[monitor.id].checks[checkDay].fails++
}
const updateResult = updateMonitorState(
monitorsState,
monitor,
operational,
status,
statusText,
checkDay,
requestTime,
checkLocation,
)

monitorsState = updateResult.monitorsState
isUpdateRequired = isUpdateRequired || updateResult.updated

try {
await sendNotifications(event, monitor, operational, monitorStatusChanged)
} catch (e) {
console.error('Failed to send notifications', e)
}
}

// Save last update information
monitorsState.lastUpdate.time = Date.now()
monitorsState.lastUpdate.loc = checkLocation

// Save monitorsState to KV storage
await setKVMonitors(monitorsState)
if (isUpdateRequired) {
monitorsState.lastUpdate.time = now
monitorsState.lastUpdate.loc = checkLocation
await setKVMonitors(monitorsState)
} else {
console.log('Skipping write status to KV!')
}

return new Response('OK')
return new Response('OK', { status: 200 })
}