snapshot of https://github.com/tldraw/tldraw.git (main, 2026-09-23; blobs > 131072B omitted)
Some checks failed
Deploy analytics worker / Deploy analytics worker to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
i18n upload strings / Upload latest strings (push) Failing after 1s
Deploy bemo / Deploy bemo to ${{ (github.ref == 'refs/heads/bemo-production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
Deploy grafana resources / ${{ github.event_name == 'push' && 'Deploy grafana resources' || 'Validate grafana resources (dry run)' }} (push) Failing after 0s
End to end tests (dotcom) / Check for relevant changes (push) Failing after 0s
End to end tests (examples) / Check for relevant changes (push) Failing after 0s
End to end tests (mermaid readability) / Check for relevant changes (push) Failing after 1s
End to end tests (perf) / Check for relevant changes (push) Failing after 0s
Publish editor extensions / Publish editor extensions (push) Failing after 1s
Publish SDK packages / Remove the publish-packages label (push) Has been skipped
Checks / Tests & checks (push) Has been cancelled
Checks / Build all projects (push) Has been cancelled
Deploy templates / Deploy templates (push) Has been cancelled
End to end tests (dotcom) / End to end tests (dotcom) (push) Has been cancelled
End to end tests (mermaid readability) / End to end tests (mermaid readability) (push) Has been cancelled
End to end tests (perf) / End to end tests (perf) (push) Has been cancelled
Deploy .com / Deploy dotcom to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Has been cancelled
End to end tests (examples) / End to end tests (examples) (push) Has been cancelled
Publish SDK packages / Publish SDK packages (push) Has been cancelled
Publish SDK packages / Tell user released version (push) Has been cancelled
Publish SDK packages / Publishes code templates to separate repositories (push) Has been cancelled
Publish SDK packages / Deploys the template demos with the new SDK version (push) Has been cancelled
Some checks failed
Deploy analytics worker / Deploy analytics worker to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
i18n upload strings / Upload latest strings (push) Failing after 1s
Deploy bemo / Deploy bemo to ${{ (github.ref == 'refs/heads/bemo-production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
Deploy grafana resources / ${{ github.event_name == 'push' && 'Deploy grafana resources' || 'Validate grafana resources (dry run)' }} (push) Failing after 0s
End to end tests (dotcom) / Check for relevant changes (push) Failing after 0s
End to end tests (examples) / Check for relevant changes (push) Failing after 0s
End to end tests (mermaid readability) / Check for relevant changes (push) Failing after 1s
End to end tests (perf) / Check for relevant changes (push) Failing after 0s
Publish editor extensions / Publish editor extensions (push) Failing after 1s
Publish SDK packages / Remove the publish-packages label (push) Has been skipped
Checks / Tests & checks (push) Has been cancelled
Checks / Build all projects (push) Has been cancelled
Deploy templates / Deploy templates (push) Has been cancelled
End to end tests (dotcom) / End to end tests (dotcom) (push) Has been cancelled
End to end tests (mermaid readability) / End to end tests (mermaid readability) (push) Has been cancelled
End to end tests (perf) / End to end tests (perf) (push) Has been cancelled
Deploy .com / Deploy dotcom to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Has been cancelled
End to end tests (examples) / End to end tests (examples) (push) Has been cancelled
Publish SDK packages / Publish SDK packages (push) Has been cancelled
Publish SDK packages / Tell user released version (push) Has been cancelled
Publish SDK packages / Publishes code templates to separate repositories (push) Has been cancelled
Publish SDK packages / Deploys the template demos with the new SDK version (push) Has been cancelled
This commit is contained in:
321
internal/scripts/fetch-screenshot-metrics.ts
Normal file
321
internal/scripts/fetch-screenshot-metrics.ts
Normal file
@@ -0,0 +1,321 @@
|
||||
/**
|
||||
* Fetch board screenshot telemetry (the mcp_shared_board_screenshot dataset events) from the
|
||||
* Cloudflare Analytics Engine SQL API and report failure rate, timeout rate, cache hit rate, and
|
||||
* Browser Run render time per request source (mcp = MCP tool, og = GET og-image route, queue = async
|
||||
* render consumer).
|
||||
*
|
||||
* Render time is wall-clock time around each Browser Run capture (double3), not Cloudflare's billed
|
||||
* browser milliseconds: the BROWSER binding does not surface a per-render billed-ms figure (double4 is
|
||||
* always -1), so wall-clock render time is the available proxy for Browser Run spend.
|
||||
*
|
||||
* Usage:
|
||||
* npx tsx internal/scripts/fetch-screenshot-metrics.ts [options]
|
||||
*
|
||||
* Examples:
|
||||
* npx tsx internal/scripts/fetch-screenshot-metrics.ts --last 24h
|
||||
* npx tsx internal/scripts/fetch-screenshot-metrics.ts --worker main-tldraw-multiplayer --last 7d
|
||||
* npx tsx internal/scripts/fetch-screenshot-metrics.ts --check --max-failure-rate 0.2 --max-render-minutes 300
|
||||
*
|
||||
* With --check the script exits non-zero when a threshold is breached, so it can back a scheduled
|
||||
* alert (cron CI job, uptime monitor, etc.) without any extra infrastructure.
|
||||
*
|
||||
* Auth: set CLOUDFLARE_ACCOUNT_ID and CLOUDFLARE_ANALYTICS_API_TOKEN (an API token with the
|
||||
* "Account Analytics: Read" permission; CLOUDFLARE_API_TOKEN works as a fallback).
|
||||
*/
|
||||
|
||||
const EVENT_NAME = 'mcp_shared_board_screenshot'
|
||||
|
||||
interface Options {
|
||||
lastHours: number
|
||||
worker?: string
|
||||
dataset: string
|
||||
json: boolean
|
||||
check: boolean
|
||||
maxFailureRate?: number
|
||||
maxTimeoutRate?: number
|
||||
minCacheHitRate?: number
|
||||
maxRenderMinutes?: number
|
||||
}
|
||||
|
||||
interface SourceMetrics {
|
||||
source: string
|
||||
requests: number
|
||||
failures: number
|
||||
timeouts: number
|
||||
rateLimited: number
|
||||
cacheHits: number
|
||||
cacheStale: number
|
||||
cacheMisses: number
|
||||
captures: number
|
||||
renderMs: number
|
||||
}
|
||||
|
||||
function parseArgs(): Options {
|
||||
const args = process.argv.slice(2)
|
||||
const opts: Options = {
|
||||
lastHours: 24,
|
||||
dataset: 'MEASURE',
|
||||
json: false,
|
||||
check: false,
|
||||
}
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const arg = args[i]
|
||||
if (arg === '--last' || arg === '-l') opts.lastHours = parseDurationHours(args[++i])
|
||||
else if (arg === '--worker' || arg === '-w') opts.worker = args[++i]
|
||||
else if (arg === '--dataset' || arg === '-d') opts.dataset = args[++i]
|
||||
else if (arg === '--json') opts.json = true
|
||||
else if (arg === '--check') opts.check = true
|
||||
else if (arg === '--max-failure-rate') opts.maxFailureRate = Number(args[++i])
|
||||
else if (arg === '--max-timeout-rate') opts.maxTimeoutRate = Number(args[++i])
|
||||
else if (arg === '--min-cache-hit-rate') opts.minCacheHitRate = Number(args[++i])
|
||||
// --max-browser-minutes is the former name; kept as an alias.
|
||||
else if (arg === '--max-render-minutes' || arg === '--max-browser-minutes')
|
||||
opts.maxRenderMinutes = Number(args[++i])
|
||||
else if (arg === '--help' || arg === '-h') {
|
||||
console.log(
|
||||
[
|
||||
'Usage: npx tsx internal/scripts/fetch-screenshot-metrics.ts [options]',
|
||||
'',
|
||||
'Options:',
|
||||
' --last, -l Look-back window, e.g. 1h, 24h, 7d (default: 24h)',
|
||||
' --worker, -w Filter to one worker name, e.g. main-tldraw-multiplayer',
|
||||
' --dataset, -d Analytics Engine dataset name (default: MEASURE)',
|
||||
' --json Print raw per-source metrics as JSON',
|
||||
' --check Exit non-zero when a threshold below is breached',
|
||||
' --max-failure-rate Threshold, 0-1 (default with --check: 0.2)',
|
||||
' --max-timeout-rate Threshold, 0-1 (default with --check: 0.1)',
|
||||
' --min-cache-hit-rate Threshold, 0-1 (no default)',
|
||||
' --max-render-minutes Browser Run render minutes in the window (no default)',
|
||||
' (alias: --max-browser-minutes)',
|
||||
' --help, -h Show this help',
|
||||
].join('\n')
|
||||
)
|
||||
process.exit(0)
|
||||
} else {
|
||||
console.error(`Unknown option: ${arg}`)
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
if (opts.check) {
|
||||
opts.maxFailureRate ??= 0.2
|
||||
opts.maxTimeoutRate ??= 0.1
|
||||
}
|
||||
return opts
|
||||
}
|
||||
|
||||
function parseDurationHours(value: string | undefined): number {
|
||||
const match = value?.match(/^(\d+)([hd])$/)
|
||||
if (!match) {
|
||||
console.error(`Invalid duration: ${value}. Use e.g. 6h, 24h, 7d.`)
|
||||
process.exit(1)
|
||||
}
|
||||
const amount = Number(match[1])
|
||||
return match[2] === 'd' ? amount * 24 : amount
|
||||
}
|
||||
|
||||
function getAuth() {
|
||||
const accountId = process.env.CLOUDFLARE_ACCOUNT_ID
|
||||
const apiToken = process.env.CLOUDFLARE_ANALYTICS_API_TOKEN ?? process.env.CLOUDFLARE_API_TOKEN
|
||||
if (!accountId || !apiToken) {
|
||||
console.error(
|
||||
'Set CLOUDFLARE_ACCOUNT_ID and CLOUDFLARE_ANALYTICS_API_TOKEN (or CLOUDFLARE_API_TOKEN).'
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
return { accountId, apiToken }
|
||||
}
|
||||
|
||||
async function queryAnalyticsEngine(sql: string): Promise<Record<string, string | number>[]> {
|
||||
const { accountId, apiToken } = getAuth()
|
||||
const response = await fetch(
|
||||
`https://api.cloudflare.com/client/v4/accounts/${accountId}/analytics_engine/sql`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${apiToken}` },
|
||||
body: sql,
|
||||
}
|
||||
)
|
||||
if (!response.ok) {
|
||||
throw new Error(`Analytics Engine query failed (${response.status}): ${await response.text()}`)
|
||||
}
|
||||
const result = (await response.json()) as { data: Record<string, string | number>[] }
|
||||
return result.data
|
||||
}
|
||||
|
||||
// Only [0-9a-zA-Z_-] survives; every dynamic value interpolated into SQL goes through this.
|
||||
function sqlSafe(value: string) {
|
||||
return value.replace(/[^0-9a-zA-Z_-]/g, '')
|
||||
}
|
||||
|
||||
async function fetchMetrics(opts: Options): Promise<SourceMetrics[]> {
|
||||
const workerFilter = opts.worker ? `AND blob2 = '${sqlSafe(opts.worker)}'` : ''
|
||||
const where = `blob1 = '${EVENT_NAME}' ${workerFilter} AND timestamp > NOW() - INTERVAL '${opts.lastHours}' HOUR`
|
||||
const dataset = sqlSafe(opts.dataset)
|
||||
|
||||
// blob3 = source, blob4 = cache status, blob5 = failure reason, blob6 = rate limit decision.
|
||||
// _sample_interval corrects for Analytics Engine sampling.
|
||||
const rows = await queryAnalyticsEngine(`
|
||||
SELECT
|
||||
blob3 AS source,
|
||||
blob4 AS cache,
|
||||
blob5 AS failure,
|
||||
blob6 AS rate_limit,
|
||||
SUM(_sample_interval) AS requests
|
||||
FROM ${dataset}
|
||||
WHERE ${where}
|
||||
GROUP BY source, cache, failure, rate_limit
|
||||
FORMAT JSON
|
||||
`)
|
||||
|
||||
// double3 is the wall-clock render duration, positive only on rows that actually invoked Browser
|
||||
// Run (cache hits and pre-render failures leave it at -1). It is the spend proxy: double4 holds
|
||||
// billed browser ms, which the BROWSER binding does not surface, so it is always -1.
|
||||
//
|
||||
// Failed captures count here too, deliberately: a render that threw still created a browser and
|
||||
// held it, often for the whole timeout, so excluding it would understate spend by exactly the
|
||||
// renders most worth knowing the cost of. Queue rows are per delivery, so a job that retried
|
||||
// three times contributes three captures — again, that is what it spent.
|
||||
const spendRows = await queryAnalyticsEngine(`
|
||||
SELECT
|
||||
blob3 AS source,
|
||||
SUM(_sample_interval) AS captures,
|
||||
SUM(double3 * _sample_interval) AS render_ms
|
||||
FROM ${dataset}
|
||||
WHERE ${where} AND double3 > 0
|
||||
GROUP BY source
|
||||
FORMAT JSON
|
||||
`)
|
||||
|
||||
const bySource = new Map<string, SourceMetrics>()
|
||||
const getSource = (source: string) => {
|
||||
let metrics = bySource.get(source)
|
||||
if (!metrics) {
|
||||
metrics = {
|
||||
source,
|
||||
requests: 0,
|
||||
failures: 0,
|
||||
timeouts: 0,
|
||||
rateLimited: 0,
|
||||
cacheHits: 0,
|
||||
cacheStale: 0,
|
||||
cacheMisses: 0,
|
||||
captures: 0,
|
||||
renderMs: 0,
|
||||
}
|
||||
bySource.set(source, metrics)
|
||||
}
|
||||
return metrics
|
||||
}
|
||||
|
||||
for (const row of rows) {
|
||||
const metrics = getSource(String(row.source).replace(/^source:/, ''))
|
||||
const requests = Number(row.requests)
|
||||
const failure = String(row.failure).replace(/^failure:/, '')
|
||||
const cache = String(row.cache).replace(/^cache:/, '')
|
||||
metrics.requests += requests
|
||||
if (failure !== 'none') metrics.failures += requests
|
||||
if (/timeout|abort/i.test(failure)) metrics.timeouts += requests
|
||||
if (String(row.rate_limit) === 'rate_limit:blocked') metrics.rateLimited += requests
|
||||
if (cache === 'hit') metrics.cacheHits += requests
|
||||
if (cache === 'stale') metrics.cacheStale += requests
|
||||
if (cache === 'miss') metrics.cacheMisses += requests
|
||||
}
|
||||
for (const row of spendRows) {
|
||||
const metrics = getSource(String(row.source).replace(/^source:/, ''))
|
||||
metrics.captures += Number(row.captures)
|
||||
metrics.renderMs += Number(row.render_ms)
|
||||
}
|
||||
|
||||
return [...bySource.values()].sort((a, b) => a.source.localeCompare(b.source))
|
||||
}
|
||||
|
||||
function rate(part: number, total: number) {
|
||||
return total === 0 ? 0 : part / total
|
||||
}
|
||||
|
||||
function formatPercent(value: number) {
|
||||
return `${(value * 100).toFixed(1)}%`
|
||||
}
|
||||
|
||||
function main() {
|
||||
const opts = parseArgs()
|
||||
return fetchMetrics(opts).then((metrics) => {
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(metrics, null, 2))
|
||||
} else {
|
||||
console.log(
|
||||
`Screenshot telemetry, last ${opts.lastHours}h${opts.worker ? ` (worker ${opts.worker})` : ''}:`
|
||||
)
|
||||
if (metrics.length === 0) console.log(' no events in window')
|
||||
for (const m of metrics) {
|
||||
console.log(
|
||||
[
|
||||
` ${m.source.padEnd(6)}`,
|
||||
`requests ${m.requests}`,
|
||||
`failure ${formatPercent(rate(m.failures, m.requests))}`,
|
||||
`timeout ${formatPercent(rate(m.timeouts, m.requests))}`,
|
||||
`cache hit ${formatPercent(rate(m.cacheHits, m.cacheHits + m.cacheStale + m.cacheMisses))}`,
|
||||
`rate-limited ${m.rateLimited}`,
|
||||
`captures ${m.captures}`,
|
||||
`render ${(m.renderMs / 60_000).toFixed(1)}min`,
|
||||
].join(' ')
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (!opts.check) return
|
||||
|
||||
const totals = metrics.reduce(
|
||||
(sum, m) => ({
|
||||
requests: sum.requests + m.requests,
|
||||
failures: sum.failures + m.failures,
|
||||
timeouts: sum.timeouts + m.timeouts,
|
||||
cacheHits: sum.cacheHits + m.cacheHits,
|
||||
cacheLookups: sum.cacheLookups + m.cacheHits + m.cacheStale + m.cacheMisses,
|
||||
renderMs: sum.renderMs + m.renderMs,
|
||||
}),
|
||||
{ requests: 0, failures: 0, timeouts: 0, cacheHits: 0, cacheLookups: 0, renderMs: 0 }
|
||||
)
|
||||
|
||||
const breaches: string[] = []
|
||||
const failureRate = rate(totals.failures, totals.requests)
|
||||
const timeoutRate = rate(totals.timeouts, totals.requests)
|
||||
const cacheHitRate = rate(totals.cacheHits, totals.cacheLookups)
|
||||
const renderMinutes = totals.renderMs / 60_000
|
||||
if (opts.maxFailureRate !== undefined && failureRate > opts.maxFailureRate) {
|
||||
breaches.push(
|
||||
`failure rate ${formatPercent(failureRate)} > ${formatPercent(opts.maxFailureRate)}`
|
||||
)
|
||||
}
|
||||
if (opts.maxTimeoutRate !== undefined && timeoutRate > opts.maxTimeoutRate) {
|
||||
breaches.push(
|
||||
`timeout rate ${formatPercent(timeoutRate)} > ${formatPercent(opts.maxTimeoutRate)}`
|
||||
)
|
||||
}
|
||||
if (
|
||||
opts.minCacheHitRate !== undefined &&
|
||||
totals.cacheLookups > 0 &&
|
||||
cacheHitRate < opts.minCacheHitRate
|
||||
) {
|
||||
breaches.push(
|
||||
`cache hit rate ${formatPercent(cacheHitRate)} < ${formatPercent(opts.minCacheHitRate)}`
|
||||
)
|
||||
}
|
||||
if (opts.maxRenderMinutes !== undefined && renderMinutes > opts.maxRenderMinutes) {
|
||||
breaches.push(
|
||||
`Browser Run render time ${renderMinutes.toFixed(1)}min > ${opts.maxRenderMinutes}min`
|
||||
)
|
||||
}
|
||||
|
||||
if (breaches.length > 0) {
|
||||
console.error(`ALERT: ${breaches.join('; ')}`)
|
||||
process.exit(1)
|
||||
}
|
||||
console.log('All thresholds OK')
|
||||
})
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.message : error)
|
||||
process.exit(1)
|
||||
})
|
||||
Reference in New Issue
Block a user