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 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.', ], } }