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