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