Created
July 24, 2025 18:05
-
-
Save maesoser/5797f73d00b340b783d0d801b4f976ff to your computer and use it in GitHub Desktop.
This Cloudflare Worker exposes an endpoint that fetches Cloudflare health check data via GraphQL, transforms it into Prometheus-compatible metrics, and caches the result for a short period.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
export default { | |
async fetch(request, env, ctx) { | |
// Only allow GET requests | |
if (request.method !== 'GET') { | |
return new Response('Method not allowed', { status: 405 }) | |
} | |
const url = new URL(request.url) | |
if (url.pathname === '/status') { | |
return new Response('OK', { status: 200 }) | |
}else if (url.pathname != '/metrics'){ | |
return new Response('Not found', { status: 404 }) | |
} | |
try { | |
// Get Cloudflare API credentials from environment variables | |
const ZONE_TAG = env.CLOUDFLARE_ZONE_TAG || ''; | |
const API_TOKEN = env.CLOUDFLARE_API_TOKEN || ''; | |
const GRAPHQL_ENDPOINT = 'https://api.cloudflare.com/client/v4/graphql'; | |
if (!API_TOKEN || !ZONE_TAG) { | |
return new Response('Missing Cloudflare env variables', { status: 500 }) | |
} | |
const now = new Date() | |
const fiveMinutesAgo = new Date(now.getTime() - 5 * 60 * 1000) | |
const cacheKey = new Request(`${url.toString()}/${ZONE_TAG}`, request); | |
const cache = caches.default; | |
let response = await cache.match(cacheKey); | |
if (!response) { | |
const checks = await fetchGraphQLData(GRAPHQL_ENDPOINT, API_TOKEN, ZONE_TAG, fiveMinutesAgo.toISOString(), now.toISOString()) | |
const metrics = convertToPrometheusMetrics(checks) | |
response = new Response(metrics, { | |
headers: { | |
'Content-Type': 'text/plain; version=0.0.4; charset=utf-8', | |
'Cache-Control': 's-maxage=150' | |
} | |
}) | |
const responseToCache = response.clone(); | |
ctx.waitUntil(cache.put(cacheKey, responseToCache)); | |
} | |
return response; | |
} catch (error) { | |
console.error('Error:', error) | |
return new Response(`Internal server error: ${error.message}`, { status: 500 }) | |
} | |
} | |
} | |
async function fetchGraphQLData(GRAPHQL_ENDPOINT, API_TOKEN, ZONE_TAG, since, until){ | |
// GraphQL query | |
const query = ` | |
query($zone: String!) { | |
viewer { | |
zones(filter: { zoneTag: $zone }) { | |
checks: healthCheckEventsAdaptive( | |
limit: 10000, | |
filter: { datetime_geq: $since, datetime_leq: $until } | |
) { | |
rttMs | |
tcpConnMs | |
timeToFirstByteMs | |
tlsHandshakeMs | |
fqdn | |
failureReason | |
healthStatus | |
originResponseStatus | |
region | |
} | |
} | |
} | |
} | |
` | |
const variables = { | |
zone: ZONE_TAG, | |
since: since, | |
until: until, | |
} | |
// Make GraphQL request to Cloudflare Analytics API | |
const graphqlResponse = await fetch(GRAPHQL_ENDPOINT, { | |
method: 'POST', | |
headers: { | |
'Content-Type': 'application/json', | |
'Authorization': `Bearer ${API_TOKEN}`, | |
}, | |
body: JSON.stringify({ query, variables: variables }) | |
}) | |
if (!graphqlResponse.ok) { | |
throw new Error(`GraphQL request failed: ${graphqlResponse.status}`) | |
} | |
const data = await graphqlResponse.json() | |
// Check for GraphQL errors | |
if (data.errors) { | |
console.error('GraphQL errors:', data.errors) | |
return new Response(`GraphQL errors: ${JSON.stringify(data.errors)}`, { status: 500 }) | |
} | |
// Extract health check data | |
const checks = data.data?.viewer?.zones?.[0]?.checks || [] | |
return checks | |
} | |
function convertToPrometheusMetrics(checks) { | |
const metrics = [] | |
// Add metadata | |
metrics.push('# HELP cloudflare_health_check_rtt_ms Round trip time in milliseconds') | |
metrics.push('# TYPE cloudflare_health_check_rtt_ms gauge') | |
metrics.push('# HELP cloudflare_health_check_tcp_conn_ms TCP connection time in milliseconds') | |
metrics.push('# TYPE cloudflare_health_check_tcp_conn_ms gauge') | |
metrics.push('# HELP cloudflare_health_check_ttfb_ms Time to first byte in milliseconds') | |
metrics.push('# TYPE cloudflare_health_check_ttfb_ms gauge') | |
metrics.push('# HELP cloudflare_health_check_tls_handshake_ms TLS handshake time in milliseconds') | |
metrics.push('# TYPE cloudflare_health_check_tls_handshake_ms gauge') | |
metrics.push('# HELP cloudflare_health_check_status Health check status (1 = healthy, 0 = unhealthy)') | |
metrics.push('# TYPE cloudflare_health_check_status gauge') | |
metrics.push('# HELP cloudflare_health_check_response_status HTTP response status code') | |
metrics.push('# TYPE cloudflare_health_check_response_status gauge') | |
// Process each health check | |
checks.forEach(check => { | |
const labels = `fqdn="${check.fqdn}",region="${check.region}",failure_reason="${check.failureReason}"` | |
// RTT metrics | |
if (check.rttMs !== null && check.rttMs !== undefined) { | |
metrics.push(`cloudflare_health_check_rtt_ms{${labels}} ${check.rttMs}`) | |
} | |
// TCP connection time | |
if (check.tcpConnMs !== null && check.tcpConnMs !== undefined) { | |
metrics.push(`cloudflare_health_check_tcp_conn_ms{${labels}} ${check.tcpConnMs}`) | |
} | |
// Time to first byte | |
if (check.timeToFirstByteMs !== null && check.timeToFirstByteMs !== undefined) { | |
metrics.push(`cloudflare_health_check_ttfb_ms{${labels}} ${check.timeToFirstByteMs}`) | |
} | |
// TLS handshake time | |
if (check.tlsHandshakeMs !== null && check.tlsHandshakeMs !== undefined) { | |
metrics.push(`cloudflare_health_check_tls_handshake_ms{${labels}} ${check.tlsHandshakeMs}`) | |
} | |
// Health status (convert to numeric) | |
const healthStatus = check.healthStatus === 'healthy' ? 1 : 0 | |
metrics.push(`cloudflare_health_check_status{${labels}} ${healthStatus}`) | |
// Response status | |
if (check.originResponseStatus !== null && check.originResponseStatus !== undefined) { | |
metrics.push(`cloudflare_health_check_response_status{${labels}} ${check.originResponseStatus}`) | |
} | |
}) | |
// Add a timestamp | |
const timestamp = Date.now() | |
metrics.push(`# EOF`) | |
return metrics.join('\n') + '\n' | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment