Last active
July 1, 2025 08:59
-
-
Save maesoser/b836fa5d63b7348462278f58f370bd8c to your computer and use it in GitHub Desktop.
Performance exporter worker
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) { | |
const ACCOUNT_TAG = env.CLOUDFLARE_ACCOUNT_TAG || ''; | |
const API_TOKEN = env.CLOUDFLARE_API_TOKEN || ''; | |
// Only handle GET requests to the metrics endpoint | |
if (request.method !== 'GET') { | |
return new Response('Method not allowed', { status: 405 }) | |
} | |
try { | |
const metrics = await fetchAndConvertMetrics(ACCOUNT_TAG, API_TOKEN) | |
return new Response(metrics, { | |
headers: { | |
'Content-Type': 'text/plain; charset=utf-8', | |
'Cache-Control': 'no-cache, no-store, must-revalidate', | |
'Pragma': 'no-cache', | |
'Expires': '0' | |
} | |
}) | |
} catch (error) { | |
console.error('Error fetching metrics:', error) | |
return new Response(`Error: ${error.message}`, { | |
status: 500, | |
headers: { 'Content-Type': 'text/plain' } | |
}) | |
} | |
}, | |
}; | |
async function fetchAndConvertMetrics(accountTag, apiToken) { | |
// Calculate time range for last 5 minutes | |
const now = new Date() | |
const fiveMinutesAgo = new Date(now.getTime() - 5 * 60 * 1000) | |
// GraphQL query and variables | |
const query = ` | |
query($account: String!, $since: Time!, $until: Time!, $filter: [HttpRequestsAdaptiveGroupsFilter!]) { | |
viewer { | |
accounts(filter: { accountTag: $account }) { | |
requests: httpRequestsAdaptiveGroups( | |
limit: 10000, | |
filter: { datetime_geq: $since, datetime_leq: $until, AND: $filter } | |
) { | |
quantiles { | |
edgeDnsResponseTimeMsP50 | |
edgeDnsResponseTimeMsP95 | |
edgeDnsResponseTimeMsP99 | |
edgeResponseBytesP50 | |
edgeResponseBytesP95 | |
edgeResponseBytesP99 | |
edgeTimeToFirstByteMsP50 | |
edgeTimeToFirstByteMsP95 | |
edgeTimeToFirstByteMsP99 | |
originResponseDurationMsP50 | |
originResponseDurationMsP95 | |
originResponseDurationMsP99 | |
} | |
avg { | |
sampleInterval | |
} | |
ratio { | |
status4xx | |
status5xx | |
} | |
dimensions { | |
clientCountryName | |
} | |
} | |
} | |
} | |
} | |
` | |
const variables = { | |
account: accountTag, | |
since: fiveMinutesAgo.toISOString(), | |
until: now.toISOString(), | |
filter: [ | |
{ "requestSource": "eyeball" }, | |
{ "cacheStatus": "dynamic" } | |
] | |
} | |
// Make GraphQL request | |
const response = await fetch('https://api.cloudflare.com/client/v4/graphql', { | |
method: 'POST', | |
headers: { | |
'Authorization': `Bearer ${apiToken}`, // Environment variable | |
'Content-Type': 'application/json', | |
}, | |
body: JSON.stringify({ | |
query: query, | |
variables: variables | |
}) | |
}) | |
if (!response.ok) { | |
throw new Error(`GraphQL request failed: ${response.status} ${response.statusText}`) | |
} | |
const data = await response.json() | |
if (data.errors) { | |
throw new Error(`GraphQL errors: ${JSON.stringify(data.errors)}`) | |
} | |
return convertToPrometheusMetrics(data.data) | |
} | |
function convertToPrometheusMetrics(data) { | |
const metrics = [] | |
const timestamp = Date.now() | |
// Add help and type comments for each metric | |
const metricDefinitions = [ | |
'# HELP cloudflare_edge_dns_response_time_ms Edge DNS response time in milliseconds', | |
'# TYPE cloudflare_edge_dns_response_time_ms gauge', | |
'# HELP cloudflare_edge_response_bytes Edge response size in bytes', | |
'# TYPE cloudflare_edge_response_bytes gauge', | |
'# HELP cloudflare_edge_time_to_first_byte_ms Time to first byte in milliseconds', | |
'# TYPE cloudflare_edge_time_to_first_byte_ms gauge', | |
'# HELP cloudflare_origin_response_duration_ms Origin response duration in milliseconds', | |
'# TYPE cloudflare_origin_response_duration_ms gauge', | |
'# HELP cloudflare_status_ratio HTTP status code ratios', | |
'# TYPE cloudflare_status_ratio gauge', | |
'# HELP cloudflare_sample_interval_avg Average sample interval', | |
'# TYPE cloudflare_sample_interval_avg gauge' | |
] | |
metrics.push(...metricDefinitions) | |
// Process each account's data | |
if (data.viewer && data.viewer.accounts) { | |
data.viewer.accounts.forEach(account => { | |
if (account.requests) { | |
account.requests.forEach(request => { | |
const country = request.dimensions?.clientCountryName || 'unknown' | |
const labels = `{country="${country}"}` | |
// DNS Response Time metrics | |
if (request.quantiles) { | |
const q = request.quantiles | |
if (q.edgeDnsResponseTimeMsP50 !== null) { | |
metrics.push(`cloudflare_edge_dns_response_time_ms${labels.replace('}', ',quantile="0.5"}')} ${q.edgeDnsResponseTimeMsP50} ${timestamp}`) | |
} | |
if (q.edgeDnsResponseTimeMsP95 !== null) { | |
metrics.push(`cloudflare_edge_dns_response_time_ms${labels.replace('}', ',quantile="0.95"}')} ${q.edgeDnsResponseTimeMsP95} ${timestamp}`) | |
} | |
if (q.edgeDnsResponseTimeMsP99 !== null) { | |
metrics.push(`cloudflare_edge_dns_response_time_ms${labels.replace('}', ',quantile="0.99"}')} ${q.edgeDnsResponseTimeMsP99} ${timestamp}`) | |
} | |
// Edge Response Bytes metrics | |
if (q.edgeResponseBytesP50 !== null) { | |
metrics.push(`cloudflare_edge_response_bytes${labels.replace('}', ',quantile="0.5"}')} ${q.edgeResponseBytesP50} ${timestamp}`) | |
} | |
if (q.edgeResponseBytesP95 !== null) { | |
metrics.push(`cloudflare_edge_response_bytes${labels.replace('}', ',quantile="0.95"}')} ${q.edgeResponseBytesP95} ${timestamp}`) | |
} | |
if (q.edgeResponseBytesP99 !== null) { | |
metrics.push(`cloudflare_edge_response_bytes${labels.replace('}', ',quantile="0.99"}')} ${q.edgeResponseBytesP99} ${timestamp}`) | |
} | |
// Time to First Byte metrics | |
if (q.edgeTimeToFirstByteMsP50 !== null) { | |
metrics.push(`cloudflare_edge_time_to_first_byte_ms${labels.replace('}', ',quantile="0.5"}')} ${q.edgeTimeToFirstByteMsP50} ${timestamp}`) | |
} | |
if (q.edgeTimeToFirstByteMsP95 !== null) { | |
metrics.push(`cloudflare_edge_time_to_first_byte_ms${labels.replace('}', ',quantile="0.95"}')} ${q.edgeTimeToFirstByteMsP95} ${timestamp}`) | |
} | |
if (q.edgeTimeToFirstByteMsP99 !== null) { | |
metrics.push(`cloudflare_edge_time_to_first_byte_ms${labels.replace('}', ',quantile="0.99"}')} ${q.edgeTimeToFirstByteMsP99} ${timestamp}`) | |
} | |
// Origin Response Duration metrics | |
if (q.originResponseDurationMsP50 !== null) { | |
metrics.push(`cloudflare_origin_response_duration_ms${labels.replace('}', ',quantile="0.5"}')} ${q.originResponseDurationMsP50} ${timestamp}`) | |
} | |
if (q.originResponseDurationMsP95 !== null) { | |
metrics.push(`cloudflare_origin_response_duration_ms${labels.replace('}', ',quantile="0.95"}')} ${q.originResponseDurationMsP95} ${timestamp}`) | |
} | |
if (q.originResponseDurationMsP99 !== null) { | |
metrics.push(`cloudflare_origin_response_duration_ms${labels.replace('}', ',quantile="0.99"}')} ${q.originResponseDurationMsP99} ${timestamp}`) | |
} | |
} | |
// Status ratio metrics | |
if (request.ratio) { | |
if (request.ratio.status4xx !== null) { | |
metrics.push(`cloudflare_status_ratio${labels.replace('}', ',status="4xx"}')} ${request.ratio.status4xx} ${timestamp}`) | |
} | |
if (request.ratio.status5xx !== null) { | |
metrics.push(`cloudflare_status_ratio${labels.replace('}', ',status="5xx"}')} ${request.ratio.status5xx} ${timestamp}`) | |
} | |
} | |
// Sample interval metric | |
if (request.avg && request.avg.sampleInterval !== null) { | |
metrics.push(`cloudflare_sample_interval_avg${labels} ${request.avg.sampleInterval} ${timestamp}`) | |
} | |
}) | |
} | |
}) | |
} | |
return metrics.join('\n') + '\n' | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment