import {
cloudflareAuthHeaders,
pickAuthOptions,
type CloudflareAuthOptions,
} from './auth.ts'
const BASE_URL = 'https://api.cloudflare.com/client/v4'
const DEFAULT_LIMIT = 50
const MAX_LIMIT = 1000
const DEFAULT_LAST = '1h'
const IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]*$/
const ORDER_BY = /^[A-Za-z0-9_]+_(ASC|DESC)$/
function assertIdentifier(value, label = 'identifier') {
const identifier = String(value || '').trim()
if (!IDENTIFIER.test(identifier)) {
throw new Error(label + ' must be a simple GraphQL identifier.')
}
return identifier
}
function normalizeArray(value, fallback = []) {
if (value === undefined || value === null) return fallback
return Array.isArray(value) ? value : [value]
}
function normalizeLimit(limit) {
const number = Number(limit ?? DEFAULT_LIMIT)
if (!Number.isFinite(number) || number < 1) {
throw new Error('limit must be a positive number.')
}
return Math.min(Math.floor(number), MAX_LIMIT)
}
function normalizeDuration(value) {
const match = String(value || '').trim().match(/^(\d+)\s*(m|h|d|w)$/i)
if (!match) {
throw new Error('last must look like 15m, 2h, 7d, or 1w.')
}
const amount = Number(match[1])
const unit = match[2].toLowerCase()
const multipliers = { m: 60_000, h: 3_600_000, d: 86_400_000, w: 604_800_000 }
return amount * multipliers[unit]
}
function toDate(value, label) {
const date = value instanceof Date ? value : new Date(value)
if (Number.isNaN(date.getTime())) {
throw new Error(label + ' must be a valid date or ISO timestamp.')
}
return date
}
function resolveTimeWindow(params) {
const until = params.until ? toDate(params.until, 'until') : new Date()
const since = params.since
? toDate(params.since, 'since')
: new Date(until.getTime() - normalizeDuration(params.last || DEFAULT_LAST))
if (since.getTime() > until.getTime()) {
throw new Error('since must be before until.')
}
return { since: since.toISOString(), until: until.toISOString() }
}
function normalizeOrderBy(orderBy) {
return normalizeArray(orderBy, ['count_DESC']).map((value) => {
const order = String(value || '').trim()
if (!ORDER_BY.test(order)) {
throw new Error('orderBy values must look like count_DESC or datetime_ASC.')
}
return order
})
}
function summarizeErrors(errors) {
return normalizeArray(errors).map((error) => ({
code: error?.code,
message: error?.message || String(error),
}))
}
async function cloudflareRequest(path, auth = {}) {
const response = await fetch(BASE_URL + path, {
headers: cloudflareAuthHeaders(pickAuthOptions(auth)),
})
const body = await response.json().catch(() => null)
if (!body) {
throw new Error('Cloudflare API returned non-JSON (' + response.status + ').')
}
return {
status: response.status,
success: body.success === true,
result: body.result,
resultInfo: body.result_info,
errors: body.errors || [],
messages: body.messages || [],
}
}
/**
* Run a raw Cloudflare GraphQL analytics query.
*/
export async function cloudflareGraphql(params = {}) {
const query = String(params.query || '').trim()
if (!query) throw new Error('query is required.')
const response = await fetch(BASE_URL + '/graphql', {
method: 'POST',
headers: {
...cloudflareAuthHeaders(pickAuthOptions(params)),
'Content-Type': 'application/json',
},
body: JSON.stringify({ query, variables: params.variables || {} }),
})
const body = await response.json().catch(() => null)
if (!body) {
throw new Error('Cloudflare GraphQL returned non-JSON (' + response.status + ').')
}
return {
status: response.status,
success: response.ok && !(Array.isArray(body.errors) && body.errors.length > 0),
data: body.data || null,
errors: body.errors || [],
}
}
/**
* Resolve a Cloudflare zone by name.
*/
export async function findZone(params = {}) {
const name = String(params.name || params.zoneName || '').trim()
if (!name) throw new Error('name or zoneName is required.')
const result = await cloudflareRequest(
'/zones?name=' + encodeURIComponent(name) + '&per_page=50',
params,
)
const zones = normalizeArray(result.result)
return {
...result,
zone: zones[0] || null,
zones,
count: zones.length,
}
}
export async function resolveZoneId(params) {
if (params.zoneId) return String(params.zoneId)
const zone = await findZone(params)
if (!zone.zone?.id) {
throw new Error('No Cloudflare zone found for ' + (params.name || params.zoneName || 'input') + '.')
}
return zone.zone.id
}
/**
* Query Cloudflare GraphQL httpRequestsAdaptiveGroups for a zone.
*
* This works on plans where Log Explorer SQL may return `missing entitlement`.
* Use it for traffic summaries by path, status, userAgent, country, or other
* dimensions available on Cloudflare's adaptive HTTP request analytics schema.
*/
export async function queryHttpRequestsAdaptiveGroups(params = {}) {
const zoneTag = await resolveZoneId(params)
const dimensions = normalizeArray(params.dimensions, ['clientRequestPath'])
.map((dimension) => assertIdentifier(dimension, 'dimension'))
const limit = normalizeLimit(params.limit)
const orderBy = normalizeOrderBy(params.orderBy)
const { since, until } = resolveTimeWindow(params)
const dimensionSelection = dimensions.join('\n ')
const orderBySelection = orderBy.join(', ')
const query = `
query($zoneTag: string!, $since: Time!, $until: Time!) {
viewer {
zones(filter: { zoneTag: $zoneTag }) {
httpRequestsAdaptiveGroups(
limit: ${limit},
filter: { datetime_geq: $since, datetime_leq: $until },
orderBy: [${orderBySelection}]
) {
count
dimensions {
${dimensionSelection}
}
}
}
}
}
`
const result = await cloudflareGraphql({
...pickAuthOptions(params),
query,
variables: { zoneTag, since, until },
})
const groups = result.data?.viewer?.zones?.[0]?.httpRequestsAdaptiveGroups || []
return {
status: result.status,
success: result.success,
zoneId: zoneTag,
since,
until,
dimensions,
groups,
count: groups.length,
errors: result.errors,
}
}
/**
* Get the common outage-forensics breakdowns for one zone.
*/
export async function summarizeHttpRequests(params = {}) {
const zoneId = await resolveZoneId(params)
const common = { ...params, zoneId }
const [byStatus, byPath, byUserAgent] = await Promise.all([
queryHttpRequestsAdaptiveGroups({
...common,
dimensions: ['edgeResponseStatus'],
limit: params.statusLimit || 20,
}),
queryHttpRequestsAdaptiveGroups({
...common,
dimensions: ['clientRequestPath'],
limit: params.pathLimit || 50,
}),
queryHttpRequestsAdaptiveGroups({
...common,
dimensions: ['userAgent'],
limit: params.userAgentLimit || 20,
}),
])
const errors = [
...summarizeErrors(byStatus.errors),
...summarizeErrors(byPath.errors),
...summarizeErrors(byUserAgent.errors),
]
return {
success: byStatus.success && byPath.success && byUserAgent.success,
zoneId,
since: byStatus.since,
until: byStatus.until,
byStatus: byStatus.groups,
byPath: byPath.groups,
byUserAgent: byUserAgent.groups,
errors,
}
}
export type CloudflareAnalyticsInput = CloudflareAuthOptions & {
query?: string
variables?: Record<string, unknown>
mode?: string
name?: string
zoneName?: string
zoneId?: string
summary?: boolean
last?: string
since?: string | Date
until?: string | Date
dimensions?: string | string[]
limit?: number
orderBy?: string | string[]
statusLimit?: number
pathLimit?: number
userAgentLimit?: number
[key: string]: unknown
}
/**
* Query Cloudflare GraphQL analytics or return helper discovery metadata.
* @param params.zoneName - Zone name for traffic summaries when `query` is omitted.
* @param params.account - Token alias (`default`, `kody`, `pages`); defaults to `default`.
* @param params.apiTokenSecret - Explicit Kody secret name when no alias fits (not a raw token).
* @returns Zone traffic breakdown or raw GraphQL data when `params.query` is set.
* @example
* import analytics from 'kody:@kentcdodds/cloudflare/analytics'
* const result = await analytics({ zoneName: 'example.com', last: '1h' })
* // => { success: true, byStatus: [...], byPath: [...], ... }
*/
export default async function cloudflareAnalytics(params: CloudflareAnalyticsInput = {}) {
if (params.query) return await cloudflareGraphql(params)
if (params.mode === 'zone' || params.name || params.zoneName) {
if (params.summary === false) return await findZone(params)
return await summarizeHttpRequests(params)
}
return {
packageId: 'cloudflare',
export: 'analytics',
requiredSecrets: [
'cloudflareApiToken',
'cloudflareApiTokenKodyAccount',
'cloudflarePagesApiToken',
],
accountAliases: ['default', 'kody', 'pages'],
helpers: [
'cloudflareGraphql({ query, variables, account? })',
'findZone({ name, account? })',
'queryHttpRequestsAdaptiveGroups({ zoneId|zoneName, dimensions, last, limit, account? })',
'summarizeHttpRequests({ zoneId|zoneName, last, account? })',
],
notes: [
'Use this export for zone traffic forensics when Log Explorer SQL is unavailable or returns missing entitlement.',
'Common dimensions include edgeResponseStatus, clientRequestPath, userAgent, clientCountryName, and datetime.',
'Pass account: "kody" (or apiTokenSecret) when the default token cannot access the target account.',
],
}
}