diff --git a/components/DetailChart.tsx b/components/DetailChart.tsx index bd50894..9d174a8 100644 --- a/components/DetailChart.tsx +++ b/components/DetailChart.tsx @@ -34,7 +34,7 @@ export default function DetailChart({ state: MonitorState }) { const { t } = useTranslation('common') - const latencyData = state.latency[monitor.id].recent.map((point) => ({ + const latencyData = state.latency[monitor.id].map((point) => ({ x: point.time * 1000, y: point.ping, loc: point.loc, diff --git a/components/MaintenanceAlert.tsx b/components/MaintenanceAlert.tsx index b460046..746d80e 100644 --- a/components/MaintenanceAlert.tsx +++ b/components/MaintenanceAlert.tsx @@ -70,7 +70,9 @@ export default function MaintenanceAlert({ {upcoming ? t('Expected end') : t('To')}
- {maintenance.end ? new Date(maintenance.end).toLocaleString() : t('Until further notice')} + {maintenance.end + ? new Date(maintenance.end).toLocaleString() + : t('Until further notice')}
diff --git a/init.sql b/init.sql index 2cb27f4..bdfe36a 100644 --- a/init.sql +++ b/init.sql @@ -2,7 +2,3 @@ CREATE TABLE IF NOT EXISTS uptimeflare ( key VARCHAR(255) PRIMARY KEY, value BLOB NOT NULL ); - -REPLACE INTO uptimeflare (key, value) VALUES ('state', '{"test": 1}'); - -INSERT INTO uptimeflare (key, value) VALUES ('state', '{"test": 1}') ON CONFLICT(key) DO UPDATE SET value = excluded.value; diff --git a/pages/api/badge.ts b/pages/api/badge.ts index 65bacce..2ba6f69 100644 --- a/pages/api/badge.ts +++ b/pages/api/badge.ts @@ -61,7 +61,7 @@ export default async function handler(req: NextRequest): Promise { const state = JSON.parse(stateStr) as MonitorState const monitorIncidentHistory = state.incident?.[monitorId] - const hasLatencyData = Boolean(state.latency?.[monitorId]?.recent?.length) + const hasLatencyData = Boolean(state.latency?.[monitorId]?.length) if (!monitorIncidentHistory || monitorIncidentHistory.length === 0 || !hasLatencyData) { return new Response(JSON.stringify(errorBadge(label, 'unknown')), { diff --git a/pages/api/data.ts b/pages/api/data.ts index fd7fe1e..88e74bb 100644 --- a/pages/api/data.ts +++ b/pages/api/data.ts @@ -29,8 +29,8 @@ export default async function handler(req: NextRequest): Promise { const isUp = state.incident[monitor.id].slice(-1)[0].end !== undefined monitors[monitor.id] = { up: isUp, - latency: state.latency[monitor.id].recent.slice(-1)[0].ping, - location: state.latency[monitor.id].recent.slice(-1)[0].loc, + latency: state.latency[monitor.id].slice(-1)[0].ping, + location: state.latency[monitor.id].slice(-1)[0].loc, message: isUp ? 'OK' : state.incident[monitor.id].slice(-1)[0].error.slice(-1)[0], } } diff --git a/pages/index.tsx b/pages/index.tsx index 94ca3cf..976a958 100644 --- a/pages/index.tsx +++ b/pages/index.tsx @@ -57,9 +57,7 @@ export default function Home({ {state == undefined ? (
- - {t('Monitor State not defined')} - + {t('Monitor State not defined')}
) : (
@@ -76,7 +74,7 @@ export default function Home({ export async function getServerSideProps() { // Read state as string from KV, to avoid hitting server-side cpu time limit - const state = await getFromStore(process.env as any, 'state') as unknown as MonitorState + const state = (await getFromStore(process.env as any, 'state')) as unknown as MonitorState // Only present these values to client const monitors = workerConfig.monitors.map((monitor) => { diff --git a/types/config.ts b/types/config.ts index 7933c01..eb5ec7d 100644 --- a/types/config.ts +++ b/types/config.ts @@ -92,32 +92,61 @@ export type Callbacks = { ) => Promise | any } +export type IncidentRecord = { + start: number[] + end: number | null // null if it's still open + error: string[] +} + +export type LatencyRecord = { + loc: string + ping: number + time: number +} + export type MonitorState = { lastUpdate: number overallUp: number overallDown: number + incident: Record + latency: Record // recent 12 hour data, N min interval +} + +// This is now the actual stored format (after TODO D1 migration) to improve (de)serialization performance +// This gives a ~3.5x speedup in computing and a 58% reduction in size +// The CPULimitExceeded issue with 10+ monitors on free tier should be largely mitigated by this change +// local profiling result (1 op = parse + stringify): +// MonitorState (original): 277 ops/s, ±0.51% | slowest, 71.09% slower +// MonitorStateCompacted: 958 ops/s, ±1.17% | fastest +export type MonitorStateCompacted = { + lastUpdate: number + overallUp: number + overallDown: number + + // incident in stored in columnar format incident: Record< - string, + string, // monitor id { - start: number[] - end: number | undefined // undefined if it's still open - error: string[] - }[] + start: number[][] + end: (number | null)[] + error: string[][] + } > + // latency in stored in columnar format + // also uses Run-length encoding for loc & Base64 encoding for number arrays latency: Record< - string, + string, // monitor id { - recent: { - loc: string - ping: number - time: number - }[] // recent 12 hour data, 2 min interval - all: { - loc: string - ping: number - time: number - }[] // all data in 90 days, 1 hour interval + loc: { + v: string[] // RLE values + c: number[] // RLE counts + } + // Hex results in a larger size and slower encoding/decoding than base64, + // but we can pop/append arbitrary number of bytes without decoding then re-encoding the whole string + // This is useful in Workers and shows a ~2% speedup comapred to base64, and it also simplifies the code + ping: string // Hex encoded Uint16Array + time: string // Hex encoded Uint32Array } > } diff --git a/util/i18n.ts b/util/i18n.ts index d3ed910..f3ffbb3 100644 --- a/util/i18n.ts +++ b/util/i18n.ts @@ -1,8 +1,8 @@ -import i18n from 'i18next'; -import { initReactI18next } from 'react-i18next'; -import LanguageDetector from 'i18next-browser-languagedetector'; -import en from '../locales/en/common.json'; -import zhCN from '../locales/zh-CN/common.json'; +import i18n from 'i18next' +import { initReactI18next } from 'react-i18next' +import LanguageDetector from 'i18next-browser-languagedetector' +import en from '../locales/en/common.json' +import zhCN from '../locales/zh-CN/common.json' i18n .use(LanguageDetector) @@ -20,6 +20,6 @@ i18n detection: { order: ['navigator'], }, - }); + }) -export default i18n; +export default i18n diff --git a/worker/src/index.ts b/worker/src/index.ts index ae02ef1..955203b 100644 --- a/worker/src/index.ts +++ b/worker/src/index.ts @@ -1,9 +1,9 @@ import { DurableObject } from 'cloudflare:workers' -import { MonitorState, MonitorTarget } from '../../types/config' -import { maintenances, workerConfig } from '../../uptime.config' +import { MonitorTarget } from '../../types/config' +import { workerConfig } from '../../uptime.config' import { getStatus, getStatusWithGlobalPing } from './monitor' -import { formatStatusChangeNotification, getWorkerLocation, webhookNotify } from './util' -import { getFromStore, setToStore } from './store' +import { formatAndNotify, getWorkerLocation } from './util' +import { CompactedMonitorStateWrapper, getFromStore, setToStore } from './store' export interface Env { UPTIMEFLARE_STATE: KVNamespace @@ -16,66 +16,12 @@ const Worker = { const workerLocation = (await getWorkerLocation()) || 'ERROR' console.log(`Running scheduled event on ${workerLocation}...`) - // Auxiliary function to format notification and send it via webhook - let formatAndNotify = async ( - monitor: MonitorTarget, - isUp: boolean, - timeIncidentStart: number, - timeNow: number, - reason: string - ) => { - // Skip notification if monitor is in the skip list - const skipList = workerConfig.notification?.skipNotificationIds - if (skipList && skipList.includes(monitor.id)) { - console.log( - `Skipping notification for ${monitor.name} (${monitor.id} in skipNotificationIds)` - ) - return - } - - // Skip notification if monitor is in maintenance - const maintenanceList = maintenances - .filter( - (m) => - new Date(timeNow * 1000) >= new Date(m.start) && - (!m.end || new Date(timeNow * 1000) <= new Date(m.end)) - ) - .map((e) => e.monitors || []) - .flat() - - if (maintenanceList.includes(monitor.id)) { - console.log(`Skipping notification for ${monitor.name} (in maintenance)`) - return - } - - if (workerConfig.notification?.webhook) { - const notification = formatStatusChangeNotification( - monitor, - isUp, - timeIncidentStart, - timeNow, - reason, - workerConfig.notification?.timeZone ?? 'Etc/GMT' - ) - await webhookNotify(workerConfig.notification.webhook, notification) - } else { - console.log(`Webhook not set, skipping notification for ${monitor.name}`) - } - } - - // Read state, set init state if it doesn't exist - let state = JSON.parse(await getFromStore(env, 'state') || '{}') as unknown as MonitorState - if (!state || Object.keys(state).length === 0) { - state = { - lastUpdate: 0, - overallUp: 0, - overallDown: 0, - incident: {}, - latency: {}, - } - } - state.overallDown = 0 - state.overallUp = 0 + // Create a wrapped MonitorState from stored compacted state + const state = new CompactedMonitorStateWrapper( + await getFromStore(env, 'state') + ) + state.data.overallDown = 0 + state.data.overallUp = 0 let statusChanged = false const currentTimeSecond = Math.round(Date.now() / 1000) @@ -126,6 +72,7 @@ const Worker = { console.log('Falling back to local check...') status = await getStatus(monitor) } else { + // TODO: more consistent error handling (throw or return?) status = { ping: 0, up: false, err: 'Unknown check proxy error' } } } @@ -134,29 +81,32 @@ const Worker = { status = await getStatus(monitor) } - // const status = await getStatus(monitor) const currentTimeSecond = Math.round(Date.now() / 1000) // Update counters - status.up ? state.overallUp++ : state.overallDown++ + status.up ? state.data.overallUp++ : state.data.overallDown++ // Update incidents // Create a dummy incident to store the start time of the monitoring and simplify logic - state.incident[monitor.id] = state.incident[monitor.id] || [ - { + if (state.incidentLen(monitor.id) === 0) { + state.appendIncident(monitor.id, { start: [currentTimeSecond], end: currentTimeSecond, error: ['dummy'], - }, - ] - // Then lastIncident here must not be undefined - let lastIncident = state.incident[monitor.id].slice(-1)[0] + }) + } + + // Then lastIncident here must not be null + let lastIncident = state.getIncident(monitor.id, state.incidentLen(monitor.id) - 1) if (status.up) { // Current status is up // close existing incident if any - if (lastIncident.end === undefined) { + if (lastIncident.end === null) { lastIncident.end = currentTimeSecond + // write back the modified last incident + state.setIncident(monitor.id, state.incidentLen(monitor.id) - 1, lastIncident) + monitorStatusChanged = true try { if ( @@ -190,24 +140,30 @@ const Worker = { } else { // Current status is down // open new incident if not already open - if (lastIncident.end !== undefined) { - state.incident[monitor.id].push({ + if (lastIncident.end !== null) { + state.appendIncident(monitor.id, { start: [currentTimeSecond], - end: undefined, + end: null, error: [status.err], }) monitorStatusChanged = true } else if ( - lastIncident.end === undefined && + lastIncident.end === null && lastIncident.error.slice(-1)[0] !== status.err ) { // append if the error message changes lastIncident.start.push(currentTimeSecond) lastIncident.error.push(status.err) + + // write back the modified last incident + state.setIncident(monitor.id, state.incidentLen(monitor.id) - 1, lastIncident) monitorStatusChanged = true } - const currentIncident = state.incident[monitor.id].slice(-1)[0] + const currentIncident = state.getIncident( + monitor.id, + state.incidentLen(monitor.id) - 1 + ) try { if ( // monitor status changed AND... @@ -284,63 +240,54 @@ const Worker = { } // append to latency data - let latencyLists = state.latency[monitor.id] || { - recent: [], - } - latencyLists.all = [] - - const record = { + state.appendLatency(monitor.id,{ loc: checkLocation, ping: status.ping, time: currentTimeSecond, - } - latencyLists.recent.push(record) + }) // discard old data - while (latencyLists.recent[0]?.time < currentTimeSecond - 12 * 60 * 60) { - latencyLists.recent.shift() + while (state.getFirstLatency(monitor.id).time < currentTimeSecond - 12 * 60 * 60) { + state.unshiftLatency(monitor.id) } - state.latency[monitor.id] = latencyLists // discard old incidents - let incidentList = state.incident[monitor.id] while ( - incidentList.length > 0 && - incidentList[0].end && - incidentList[0].end < currentTimeSecond - 90 * 24 * 60 * 60 + state.incidentLen(monitor.id) > 0 && + state.getIncident(monitor.id, 0).end && + state.getIncident(monitor.id, 0).end! < currentTimeSecond - 90 * 24 * 60 * 60 ) { - incidentList.shift() + state.shiftIncident(monitor.id) } if ( - incidentList.length == 0 || - (incidentList[0].start[0] > currentTimeSecond - 90 * 24 * 60 * 60 && - incidentList[0].error[0] != 'dummy') + state.incidentLen(monitor.id) === 0 || + (state.getIncident(monitor.id, 0).start[0] > currentTimeSecond - 90 * 24 * 60 * 60 && + state.getIncident(monitor.id, 0).error[0] != 'dummy') ) { // put the dummy incident back - incidentList.unshift({ + state.unshiftIncident(monitor.id, { start: [currentTimeSecond - 90 * 24 * 60 * 60], end: currentTimeSecond - 90 * 24 * 60 * 60, error: ['dummy'], }) } - state.incident[monitor.id] = incidentList statusChanged ||= monitorStatusChanged } console.log( - `statusChanged: ${statusChanged}, lastUpdate: ${state.lastUpdate}, currentTime: ${currentTimeSecond}` + `statusChanged: ${statusChanged}, lastUpdate: ${state.data.lastUpdate}, currentTime: ${currentTimeSecond}` ) // Update state - // Allow for a cooldown period before writing to KV + // Allow for a cooldown period before writing to storage if ( statusChanged || - currentTimeSecond - state.lastUpdate >= (workerConfig.kvWriteCooldownMinutes ?? 3) * 60 - 10 // Allow for 10 seconds of clock drift + currentTimeSecond - state.data.lastUpdate >= (workerConfig.kvWriteCooldownMinutes ?? 3) * 60 - 10 // Allow for 10 seconds of clock drift ) { console.log('Updating state...') - state.lastUpdate = currentTimeSecond - await setToStore(env, 'state', JSON.stringify(state)) + state.data.lastUpdate = currentTimeSecond + await setToStore(env, 'state', state.getCompactedStateStr()) } else { console.log('Skipping state update due to cooldown period.') } diff --git a/worker/src/monitor.ts b/worker/src/monitor.ts index eb9be2e..4ad4e77 100644 --- a/worker/src/monitor.ts +++ b/worker/src/monitor.ts @@ -304,7 +304,9 @@ export async function getStatus( response.status, response.text.bind(response) ) - try { await response.body?.cancel() } catch(e) {} // Always try to cancel body, see issue #166 + try { + await response.body?.cancel() + } catch (e) {} // Always try to cancel body, see issue #166 if (err !== null) { console.log(`${monitor.name} didn't pass response check: ${err}`) diff --git a/worker/src/store.ts b/worker/src/store.ts index fed56f2..775401b 100644 --- a/worker/src/store.ts +++ b/worker/src/store.ts @@ -1,17 +1,155 @@ -import { Env } from "."; +import { Env } from '.' +import { IncidentRecord, LatencyRecord, MonitorStateCompacted } from '../../types/config' export async function getFromStore(env: Env, key: string): Promise { - const stmt = env.UPTIMEFLARE_D1.prepare("SELECT value FROM uptimeflare WHERE key = ?") - const result = await stmt.bind(key).first<{ value: string }>() - - if (!result) { - return await env.UPTIMEFLARE_STATE.get(key) - } - - return result?.value || null + const stmt = env.UPTIMEFLARE_D1.prepare('SELECT value FROM uptimeflare WHERE key = ?') + const result = await stmt.bind(key).first<{ value: string }>() + return result?.value || null } export async function setToStore(env: Env, key: string, value: string): Promise { - const stmt = env.UPTIMEFLARE_D1.prepare("INSERT INTO uptimeflare (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value;") - await stmt.bind(key, value).run() + const stmt = env.UPTIMEFLARE_D1.prepare( + 'INSERT INTO uptimeflare (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value;' + ) + await stmt.bind(key, value).run() +} + +export class CompactedMonitorStateWrapper { + data: MonitorStateCompacted + + constructor(compactedStateStr: string | null) { + if (!compactedStateStr) { + // Initialize empty state + this.data = { + lastUpdate: 0, + overallUp: 0, + overallDown: 0, + incident: {}, + latency: {}, + } + return + } + this.data = JSON.parse(compactedStateStr) + } + + getCompactedStateStr(): string { + return JSON.stringify(this.data) + } + + incidentLen(monitorId: string): number { + const incidents = this.data.incident[monitorId] + if (!incidents) return 0 + return incidents.start.length + } + + getIncident(monitorId: string, index: number): IncidentRecord { + const incidents = this.data.incident[monitorId] + if (!incidents || index < 0 || index >= incidents.start.length) { + throw new Error('Index out of bounds or monitor not found') + } + return { + start: incidents.start[index], + end: incidents.end[index], + error: incidents.error[index], + } + } + + setIncident(monitorId: string, index: number, incident: IncidentRecord) { + const incidents = this.data.incident[monitorId] + if (!incidents || index < 0 || index >= incidents.start.length) { + throw new Error('Index out of bounds or monitor not found') + } + incidents.start[index] = incident.start + incidents.end[index] = incident.end + incidents.error[index] = incident.error + } + + appendIncident(monitorId: string, incident: IncidentRecord) { + let incidents = this.data.incident[monitorId] + if (!incidents) { + // Initialize incident arrays + this.data.incident[monitorId] = { + start: [], + end: [], + error: [], + } + incidents = this.data.incident[monitorId] + } + incidents.start.push(incident.start) + incidents.end.push(incident.end) + incidents.error.push(incident.error) + } + + shiftIncident(monitorId: string) { + const incidents = this.data.incident[monitorId] + incidents.start.shift() + incidents.end.shift() + incidents.error.shift() + } + + unshiftIncident(monitorId: string, incident: IncidentRecord) { + const incidents = this.data.incident[monitorId] + incidents.start.unshift(incident.start) + incidents.end.unshift(incident.end) + incidents.error.unshift(incident.error) + } + + latencyLen(monitorId: string): number { + const latencies = this.data.latency[monitorId] + if (!latencies) return 0 + return latencies.ping.length / 4 // Uint16Array, 4 characters per entry in hex + } + + appendLatency(monitorId: string, record: LatencyRecord) { + let latencies = this.data.latency[monitorId] + if (!latencies) { + // Initialize latency arrays + this.data.latency[monitorId] = { + time: '', + ping: '', + loc: { + c: [], + v: [], + }, + } + latencies = this.data.latency[monitorId] + } + + // @ts-expect-error + latencies.time += new Uint8Array(new Uint32Array([record.time]).buffer).toHex() + // @ts-expect-error + latencies.ping += new Uint8Array(new Uint16Array([record.ping]).buffer).toHex() + + if (latencies.loc.v[latencies.loc.v.length - 1] !== record.loc) { + latencies.loc.c.push(1) + latencies.loc.v.push(record.loc) + } else { + latencies.loc.c[latencies.loc.c.length - 1] += 1 + } + } + + getFirstLatency(monitorId: string): LatencyRecord { + let latencies = this.data.latency[monitorId] + + return { + // @ts-expect-error + time: new Uint32Array(Uint8Array.fromHex(latencies.time.slice(0, 8)).buffer)[0], + // @ts-expect-error + ping: new Uint16Array(Uint8Array.fromHex(latencies.ping.slice(0, 4)).buffer)[0], + loc: latencies.loc.v[0], + } + } + + unshiftLatency(monitorId: string) { + let latencies = this.data.latency[monitorId] + + latencies.time = latencies.time.slice(8) + latencies.ping = latencies.ping.slice(4) + + latencies.loc.c[0] -= 1 + if (latencies.loc.c[0] === 0) { + latencies.loc.c.shift() + latencies.loc.v.shift() + } + } } diff --git a/worker/src/util.ts b/worker/src/util.ts index 0cbe5ad..bd18c50 100644 --- a/worker/src/util.ts +++ b/worker/src/util.ts @@ -1,4 +1,5 @@ -import { WebhookConfig } from '../../types/config' +import { MonitorTarget, WebhookConfig } from '../../types/config' +import { maintenances, workerConfig } from '../../uptime.config' async function getWorkerLocation() { const res = await fetch('https://cloudflare.com/cdn-cgi/trace') @@ -145,10 +146,59 @@ async function webhookNotify(webhook: WebhookConfig, message: string) { } } +// Auxiliary function to format notification and send it via webhook +const formatAndNotify = async ( + monitor: MonitorTarget, + isUp: boolean, + timeIncidentStart: number, + timeNow: number, + reason: string +) => { + // Skip notification if monitor is in the skip list + const skipList = workerConfig.notification?.skipNotificationIds + if (skipList && skipList.includes(monitor.id)) { + console.log( + `Skipping notification for ${monitor.name} (${monitor.id} in skipNotificationIds)` + ) + return + } + + // Skip notification if monitor is in maintenance + const maintenanceList = maintenances + .filter( + (m) => + new Date(timeNow * 1000) >= new Date(m.start) && + (!m.end || new Date(timeNow * 1000) <= new Date(m.end)) + ) + .map((e) => e.monitors || []) + .flat() + + if (maintenanceList.includes(monitor.id)) { + console.log(`Skipping notification for ${monitor.name} (in maintenance)`) + return + } + + if (workerConfig.notification?.webhook) { + const notification = formatStatusChangeNotification( + monitor, + isUp, + timeIncidentStart, + timeNow, + reason, + workerConfig.notification?.timeZone ?? 'Etc/GMT' + ) + await webhookNotify(workerConfig.notification.webhook, notification) + } else { + console.log(`Webhook not set, skipping notification for ${monitor.name}`) + } +} + + export { getWorkerLocation, fetchTimeout, withTimeout, webhookNotify, formatStatusChangeNotification, + formatAndNotify, } diff --git a/worker/wrangler-dev.toml b/worker/wrangler-dev.toml index 6dcc951..022ce15 100644 --- a/worker/wrangler-dev.toml +++ b/worker/wrangler-dev.toml @@ -1,5 +1,9 @@ name = "uptimeflare_worker" main = "src/index.ts" compatibility_date = "2025-04-02" -kv_namespaces = [{ binding = "UPTIMEFLARE_STATE", id = "UPTIMEFLARE_STATE" }] compatibility_flags = [ "nodejs_compat" ] + +[[d1_databases]] +binding = "UPTIMEFLARE_D1" +database_name = "uptimeflare_d1" +database_id = "00000000-0000-0000-0000-000000000000" diff --git a/worker/wrangler.toml b/worker/wrangler.toml index eda714e..0ab2b92 100644 --- a/worker/wrangler.toml +++ b/worker/wrangler.toml @@ -2,3 +2,4 @@ name = "uptimeflare_worker" main = "src/index.ts" compatibility_date = "2025-04-02" compatibility_flags = [ "nodejs_compat" ] + diff --git a/wrangler.toml b/wrangler.toml new file mode 100644 index 0000000..33c09e7 --- /dev/null +++ b/wrangler.toml @@ -0,0 +1,8 @@ +name = "uptimeflare" +compatibility_date = "2025-04-02" +compatibility_flags = [ "nodejs_compat" ] + +[[d1_databases]] +binding = "UPTIMEFLARE_D1" +database_name = "uptimeflare_d1" +database_id = "00000000-0000-0000-0000-000000000000"