Skip to content

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

Package listing

@kentcdodds/cloudflare

src/observability-logs.ts

449 lines · 16.6 KB · TypeScript
import { queryHttpRequestsAdaptiveGroups, resolveZoneId, summarizeHttpRequests } from './analytics.ts'
import {
  cloudflareAuthHeaders,
  pickAuthOptions,
  type CloudflareAuthOptions,
} from './auth.ts'

const BASE_URL = 'https://api.cloudflare.com'
const DEFAULT_DATASET = 'workers_trace_events'
const DEFAULT_LIMIT = 50
const MAX_LIMIT = 1000
const SQL_IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]*$/
const ORDER_DIRECTIONS = new Set(['ASC', 'DESC'])
const ALLOWED_SCOPES = new Set(['account', 'zone'])
const NO_VALUE_OPERATORS = new Set(['IS NULL', 'IS NOT NULL'])
const LIST_OPERATORS = new Set(['IN', 'NOT IN'])
const DEFAULT_TIMESTAMP_FIELDS = {
  workers_trace_events: 'EventTimestampMs',
  access_requests: 'CreatedAt',
  http_requests: 'EdgeStartTimestamp',
}
const DEFAULT_TEXT_FIELDS = {
  workers_trace_events: ['Logs', 'Exceptions', 'Event'],
  access_requests: ['Email', 'AppDomain', 'Action', 'RayID', 'IPAddress'],
  http_requests: ['clientRequestPath', 'clientRequestUserAgent', 'clientRequestHost', 'RayID'],
}

function assertIdentifier(value, label = 'identifier') {
  const identifier = String(value || '').trim()
  if (!SQL_IDENTIFIER.test(identifier)) {
    throw new Error(label + ' must be a simple SQL identifier.')
  }
  return identifier
}

function assertSql(sql) {
  const value = String(sql || '').trim().replace(/;+\s*$/, '')
  if (!/^(select|with)\b/i.test(value)) {
    throw new Error('sql must be a SELECT or WITH query.')
  }
  if (value.includes(';')) {
    throw new Error('sql must contain a single query without semicolons.')
  }
  if (value.length > 20000) {
    throw new Error('sql exceeds maximum length.')
  }
  return value
}

function normalizeDataset(dataset) {
  return assertIdentifier(dataset || DEFAULT_DATASET, 'dataset')
}

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 quoteSqlString(value) {
  return "'" + String(value).split("'").join("''") + "'"
}

function sqlLiteral(value) {
  if (value === null) return 'NULL'
  if (typeof value === 'number') {
    if (!Number.isFinite(value)) throw new Error('number filter values must be finite.')
    return String(value)
  }
  if (typeof value === 'boolean') return value ? 'true' : 'false'
  if (value instanceof Date) return quoteSqlString(value.toISOString())
  return quoteSqlString(value)
}

function normalizeArray(value, fallback = []) {
  if (value === undefined || value === null) return fallback
  return Array.isArray(value) ? value : [value]
}

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) {
  if (params.allTime) return { since: null, until: null }
  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 || '1h'))
  if (since.getTime() > until.getTime()) {
    throw new Error('since must be before until.')
  }
  return { since, until }
}

async function normalizeScope(params) {
  const scope = params.scope || (params.zoneId || params.zoneName || params.name ? 'zone' : 'account')
  if (!ALLOWED_SCOPES.has(scope)) {
    throw new Error('scope must be account or zone.')
  }
  if (scope === 'zone') {
    if (params.zoneId) return { scope, objectId: String(params.zoneId) }
    if (params.zoneName || params.name) {
      return { scope, objectId: await resolveZoneId(params) }
    }
    throw new Error('zone-scoped Log Explorer queries accept zoneId or zoneName.')
  }
  if (!params.accountId) {
    throw new Error('accountId is required for account-scoped Log Explorer queries.')
  }
  return { scope, objectId: String(params.accountId) }
}

function normalizeColumns(columns) {
  if (!columns || columns === '*') return '*'
  const values = normalizeArray(columns)
  if (values.length === 0) return '*'
  return values.map((column) => assertIdentifier(column, 'column')).join(', ')
}

function normalizeOperator(operator = '=') {
  const normalized = String(operator).trim().toUpperCase()
  const allowed = ['=', '!=', '<>', '>', '>=', '<', '<=', 'LIKE', 'NOT LIKE', 'IN', 'NOT IN', 'IS NULL', 'IS NOT NULL']
  if (!allowed.includes(normalized)) {
    throw new Error('unsupported filter operator: ' + operator)
  }
  return normalized
}

function filterClause(filter) {
  const field = assertIdentifier(filter.field, 'filter field')
  const operator = normalizeOperator(filter.operator || filter.op)
  if (NO_VALUE_OPERATORS.has(operator)) return field + ' ' + operator
  const value = filter.value !== undefined ? filter.value : filter.values
  if (LIST_OPERATORS.has(operator)) {
    const values = normalizeArray(value)
    if (values.length === 0) throw new Error(operator + ' filters need at least one value.')
    return field + ' ' + operator + ' (' + values.map(sqlLiteral).join(', ') + ')'
  }
  if (value === undefined) throw new Error('filter value is required for ' + field + '.')
  return field + ' ' + operator + ' ' + sqlLiteral(value)
}

function pushEquality(filters, field, value) {
  if (value === undefined || value === null || value === '') return
  filters.push({ field, operator: '=', value })
}

function pushLike(filters, field, value, suffix = '') {
  if (value === undefined || value === null || value === '') return
  filters.push({ field, operator: 'LIKE', value: String(value) + suffix })
}

function addConvenienceFilters(params, filters) {
  pushEquality(filters, params.rayIdField || 'RayID', params.rayId)
  pushEquality(filters, params.methodField || 'clientRequestMethod', params.method)
  pushEquality(filters, params.hostField || 'clientRequestHost', params.host)
  pushEquality(filters, params.scriptNameField || 'ScriptName', params.scriptName || params.workerName)
  pushEquality(filters, params.outcomeField || 'Outcome', params.outcome)
  pushLike(filters, params.pathField || 'clientRequestPath', params.pathPrefix, '%')
  pushEquality(filters, params.pathField || 'clientRequestPath', params.path)

  if (params.status !== undefined) {
    pushEquality(filters, params.statusField || 'edgeResponseStatus', params.status)
  }
  if (params.statusRange !== undefined) {
    const text = String(params.statusRange).trim().toLowerCase()
    const base = text.endsWith('xx') ? Number(text[0]) * 100 : Number(text)
    if (!Number.isFinite(base)) throw new Error('statusRange must look like 5xx or 500.')
    const field = params.statusField || 'edgeResponseStatus'
    filters.push({ field, operator: '>=', value: base })
    filters.push({ field, operator: '<', value: base + 100 })
  }
}

function textClause(params, dataset) {
  const text = params.text ?? (looksLikeSql(params.query) ? undefined : params.query)
  if (!text) return null
  const fields = normalizeArray(params.textFields, DEFAULT_TEXT_FIELDS[dataset] || [])
  if (fields.length === 0) {
    throw new Error('textFields is required when text is provided for this dataset.')
  }
  const pattern = '%' + String(text) + '%'
  return '(' + fields.map((field) => {
    const identifier = assertIdentifier(field, 'text field')
    return 'toString(' + identifier + ') LIKE ' + sqlLiteral(pattern)
  }).join(' OR ') + ')'
}

function timeClauses(params, dataset) {
  const { since, until } = resolveTimeWindow(params)
  if (!since || !until) return []
  const timestampField = params.timestampField === false
    ? null
    : assertIdentifier(params.timestampField || DEFAULT_TIMESTAMP_FIELDS[dataset] || 'EventTimestampMs', 'timestampField')
  if (!timestampField) return []
  if (timestampField === 'EventTimestampMs') {
    return [timestampField + ' >= ' + since.getTime(), timestampField + ' <= ' + until.getTime()]
  }
  return [
    timestampField + ' >= ' + sqlLiteral(since.toISOString()),
    timestampField + ' <= ' + sqlLiteral(until.toISOString()),
  ]
}

function normalizeOrderBy(orderBy, dataset, params) {
  if (orderBy === false) return ''
  const orders = normalizeArray(orderBy, [])
  if (orders.length === 0) {
    const defaultField = params.timestampField || DEFAULT_TIMESTAMP_FIELDS[dataset]
    return defaultField ? ' ORDER BY ' + assertIdentifier(defaultField, 'orderBy field') + ' DESC' : ''
  }
  const clauses = orders.map((order) => {
    if (typeof order === 'string') return assertIdentifier(order, 'orderBy field') + ' DESC'
    const field = assertIdentifier(order.field, 'orderBy field')
    const direction = String(order.direction || 'DESC').trim().toUpperCase()
    if (!ORDER_DIRECTIONS.has(direction)) throw new Error('order direction must be ASC or DESC.')
    return field + ' ' + direction
  })
  return ' ORDER BY ' + clauses.join(', ')
}

function looksLikeSql(value) {
  return /^(select|with)\b/i.test(String(value || '').trim())
}

function buildEndpoint(scope, objectId) {
  const encodedId = encodeURIComponent(objectId)
  const path = scope === 'zone'
    ? '/client/v4/zones/' + encodedId + '/logs/explorer/query/sql'
    : '/client/v4/accounts/' + encodedId + '/logs/explorer/query/sql'
  return new URL(path, BASE_URL)
}

function logExplorerHints(errors) {
  const values = normalizeArray(errors)
  if (values.some((error) => error?.code === 10004 || /missing entitlement/i.test(error?.message || ''))) {
    return [
      'Cloudflare Log Explorer is not enabled for this account or token scope.',
      'Use kody:@kentcdodds/cloudflare/analytics for GraphQL HTTP request aggregates.',
    ]
  }
  return []
}

function hasMissingEntitlement(errors) {
  const values = normalizeArray(errors)
  return values.some((error) => error?.code === 10004 || /missing entitlement/i.test(error?.message || ''))
}

async function analyticsFallback(params, errors) {
  if (params.fallbackToAnalytics === false || !params.zoneId) return null
  if (!hasMissingEntitlement(errors)) return null
  const auth = pickAuthOptions(params)
  if (params.analyticsSummary !== false) {
    const summary = await summarizeHttpRequests({
      ...auth,
      zoneId: params.zoneId,
      last: params.last,
      since: params.since,
      until: params.until,
      pathLimit: params.pathLimit || params.limit,
      statusLimit: params.statusLimit,
      userAgentLimit: params.userAgentLimit,
    })
    return {
      fallback: 'analytics',
      reason: 'Cloudflare Log Explorer returned missing entitlement.',
      summary,
    }
  }
  const dimensions = params.analyticsDimensions || ['clientRequestPath']
  const groups = await queryHttpRequestsAdaptiveGroups({
    ...auth,
    zoneId: params.zoneId,
    dimensions,
    last: params.last,
    since: params.since,
    until: params.until,
    limit: params.limit,
  })
  return {
    fallback: 'analytics',
    reason: 'Cloudflare Log Explorer returned missing entitlement.',
    groups,
  }
}

/**
 * Builds a Cloudflare Log Explorer SQL query from common log-search inputs.
 *
 * Use this when you want to inspect the generated query, share it with another
 * workflow, or pass it to `queryLogExplorerSql`. Defaults target the
 * account-scoped `workers_trace_events` dataset, the last hour, and a limit of
 * 50 rows. Pass `allTime: true` to omit the default time window.
 */
export function buildLogExplorerSql(params = {}) {
  const dataset = normalizeDataset(params.dataset)
  const columns = normalizeColumns(params.columns)
  const filters = normalizeArray(params.filters)
  addConvenienceFilters(params, filters)

  const clauses = [
    ...timeClauses(params, dataset),
    ...filters.map(filterClause),
  ]
  const text = textClause(params, dataset)
  if (text) clauses.push(text)

  const where = clauses.length ? ' WHERE ' + clauses.join(' AND ') : ''
  const orderBy = normalizeOrderBy(params.orderBy, dataset, params)
  const limit = ' LIMIT ' + normalizeLimit(params.limit)
  return 'SELECT ' + columns + ' FROM ' + dataset + where + orderBy + limit
}

/**
 * Runs a raw Cloudflare Log Explorer SQL query against an account or zone.
 *
 * Pass `accountId` for account-scoped datasets such as `workers_trace_events`
 * or `access_requests`; pass `zoneId` for zone-scoped datasets such as
 * `http_requests`. The query must be a single `SELECT` or `WITH` query. The
 * selected Cloudflare API token (`account` / `apiTokenSecret`, defaulting
 * to `cloudflareApiToken`) against the Log Explorer SQL API endpoint under
 * `/logs/explorer/query/sql`.
 */
export async function queryLogExplorerSql(params = {}) {
  const sql = assertSql(params.sql || params.query)
  const { scope, objectId } = await normalizeScope(params)
  const url = buildEndpoint(scope, objectId)

  const response = await fetch(url.toString(), {
    method: 'POST',
    headers: {
      ...cloudflareAuthHeaders(pickAuthOptions(params)),
      'Content-Type': 'text/plain',
    },
    body: sql,
  })
  const body = await response.json().catch(() => null)
  if (!body) {
    throw new Error('Cloudflare Log Explorer returned non-JSON (' + response.status + ').')
  }
  const result = {
    status: response.status,
    success: body.success === true,
    scope,
    objectId,
    sql,
    result: Array.isArray(body.result) ? body.result : [],
    count: Array.isArray(body.result) ? body.result.length : 0,
    errors: body.errors || [],
    messages: body.messages || [],
    hints: logExplorerHints(body.errors),
  }
  const fallback = await analyticsFallback(scope === 'zone' ? { ...params, zoneId: objectId } : params, result.errors)
  if (fallback) result.analyticsFallback = fallback
  return result
}

export type CloudflareObservabilityLogsInput = CloudflareAuthOptions & {
  accountId?: string
  zoneId?: string
  zoneName?: string
  name?: string
  dataset?: string
  text?: string
  query?: string
  sql?: string
  scriptName?: string
  workerName?: string
  rayId?: string
  outcome?: string
  statusRange?: string
  host?: string
  pathPrefix?: string
  path?: string
  last?: string
  since?: string | Date
  until?: string | Date
  limit?: number
  dryRun?: boolean
  fallbackToAnalytics?: boolean
  filters?: Array<{ field: string; operator?: string; op?: string; value?: unknown; values?: unknown[] }>
  [key: string]: unknown
}

/**
 * Search Cloudflare Log Explorer with structured filters or custom SQL.
 * @param params.accountId - Account id for account-scoped datasets such as `workers_trace_events`.
 * @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 Matching log rows, generated SQL when `dryRun: true`, or an analytics fallback on missing entitlement.
 * @example
 * import searchLogs from 'kody:@kentcdodds/cloudflare/observability-logs'
 * const result = await searchLogs({ accountId: 'acct-id', scriptName: 'my-worker', last: '30m' })
 * // => { success: true, result: [...], count: 25, ... }
 */
export default async function searchCloudflareObservabilityLogs(params: CloudflareObservabilityLogsInput = {}) {
  const sql = params.sql || (looksLikeSql(params.query) ? params.query : buildLogExplorerSql(params))
  if (params.dryRun) return { sql: assertSql(sql) }
  return await queryLogExplorerSql({ ...params, sql })
}

/**
 * Describes the log-search helper surface for discovery workflows.
 *
 * Use this from agents that need to decide whether to run a structured search,
 * generate SQL for review, or submit custom Log Explorer SQL directly.
 */
export function describeCloudflareLogSearch() {
  return {
    packageId: 'cloudflare',
    export: 'observability-logs',
    defaultDataset: DEFAULT_DATASET,
    defaultLimit: DEFAULT_LIMIT,
    maxLimit: MAX_LIMIT,
    requiredSecrets: [
      'cloudflareApiToken',
      'cloudflareApiTokenKodyAccount',
      'cloudflarePagesApiToken',
    ],
    accountAliases: ['default', 'kody', 'pages'],
    fallbackExport: 'analytics',
    examples: [
      'workers_trace_events: accountId + scriptName + outcome + text + last',
      'http_requests: zoneId + rayId, statusRange, host, pathPrefix, last',
      'access_requests: accountId + sql for audit and identity investigations',
      'analytics fallback: kody:@kentcdodds/cloudflare/analytics summarizeHttpRequests({ zoneName, last })',
      'token selection: pass account: "kody" for the Kody Cloudflare account',
    ],
  }
}