Skip to content

Built for people who want to own their automations. Join the waitlist for an invite.

Package listing

@kentcdodds/cloudflare

src/analytics.ts

305 lines · 9.5 KB · TypeScript
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.',
    ],
  }
}