diff --git a/apps/sim/blocks/blocks/cloudflare.ts b/apps/sim/blocks/blocks/cloudflare.ts index ea82789ea40..21f4ed31db8 100644 --- a/apps/sim/blocks/blocks/cloudflare.ts +++ b/apps/sim/blocks/blocks/cloudflare.ts @@ -1019,26 +1019,20 @@ Return ONLY the timestamp or relative expression - no explanations, no quotes, n id: 'metrics', title: 'Metrics', type: 'short-input', - placeholder: 'Comma-separated (e.g., queryCount,uncachedCount,responseTimeAvg)', + placeholder: 'queryCount (the only metric in the GraphQL DNS analytics dataset)', condition: { field: 'operation', value: 'dns_analytics' }, wandConfig: { enabled: true, prompt: `Generate a comma-separated list of Cloudflare DNS Analytics metrics based on the user's description. -Available metrics: +Available metrics (Cloudflare GraphQL Analytics API, dnsAnalyticsAdaptiveGroups): - queryCount: Total number of DNS queries -- uncachedCount: Number of DNS queries not served from cache -- staleCount: Number of stale DNS responses served -- responseTimeAvg: Average response time in milliseconds -- responseTimeMedian: Median response time in milliseconds -- responseTime90th: 90th percentile response time -- responseTime99th: 99th percentile response time + +Cache and response-time metrics from the old REST report are not available. Examples: - "query counts" -> queryCount -- "all query metrics" -> queryCount,uncachedCount,staleCount -- "response times" -> responseTimeAvg,responseTimeMedian,responseTime90th,responseTime99th -- "everything" -> queryCount,uncachedCount,staleCount,responseTimeAvg,responseTimeMedian,responseTime90th,responseTime99th +- "everything" -> queryCount Return ONLY the comma-separated metric names - no explanations, no quotes, no extra text.`, placeholder: 'Describe what to measure (e.g., "query counts and response times")...', @@ -1059,18 +1053,11 @@ Available dimensions: - queryName: DNS record name being queried - queryType: DNS query type (A, AAAA, CNAME, MX, etc.) - responseCode: DNS response code (NOERROR, NXDOMAIN, SERVFAIL, etc.) -- responseCached: Whether the response was cached -- coloName: Cloudflare data center handling the query -- origin: Origin server -- dayOfWeek: Day of the week -- tcp: Whether the query used TCP -- ipVersion: IP version (4 or 6) Examples: - "by record type" -> queryType - "by record name and type" -> queryName,queryType -- "by data center" -> coloName -- "by response code and cache status" -> responseCode,responseCached +- "by response code" -> responseCode Return ONLY the comma-separated dimension names - no explanations, no quotes, no extra text.`, placeholder: 'Describe how to group results (e.g., "by record type and name")...', @@ -1087,23 +1074,19 @@ Return ONLY the comma-separated dimension names - no explanations, no quotes, no enabled: true, prompt: `Generate a Cloudflare DNS Analytics filter expression based on the user's description. -Filter syntax: field==value or field!=value +Filter syntax: field==value Multiple filters can be combined with semicolons: field1==value1;field2==value2 Available filter fields: - queryType: DNS record type (A, AAAA, CNAME, MX, TXT, NS, SRV, etc.) - queryName: DNS record name - responseCode: DNS response code (NOERROR, NXDOMAIN, SERVFAIL, REFUSED) -- responseCached: Whether cached (0 or 1) -- coloName: Data center name -- origin: Origin server Examples: - "only A records" -> queryType==A - "only CNAME records" -> queryType==CNAME - "failed queries" -> responseCode==SERVFAIL - "non-existent domains" -> responseCode==NXDOMAIN -- "A records that weren't cached" -> queryType==A;responseCached==0 - "queries for example.com" -> queryName==example.com Return ONLY the filter expression - no explanations, no quotes, no extra text.`, diff --git a/apps/sim/tools/cloudflare/cloudflare.test.ts b/apps/sim/tools/cloudflare/cloudflare.test.ts index e7dc79830b6..8ab4f345aa8 100644 --- a/apps/sim/tools/cloudflare/cloudflare.test.ts +++ b/apps/sim/tools/cloudflare/cloudflare.test.ts @@ -652,39 +652,98 @@ function envelope(result: unknown, success = true): Response { }) } -describe('DNS analytics does not fabricate metrics Cloudflare did not return', () => { +/** Builds a GraphQL Analytics response for the dnsAnalyticsAdaptiveGroups dataset. */ +function dnsGraphql( + groups: Array<{ count: number; dimensions?: Record }>, + errors: Array<{ message: string }> | null = null +): Response { + return new Response( + JSON.stringify({ + data: { viewer: { zones: [{ dnsAnalyticsAdaptiveGroups: groups }] } }, + errors, + }), + { headers: { 'Content-Type': 'application/json' } } + ) +} + +describe('DNS analytics reads the GraphQL Analytics API', () => { const tool = cloudflareTools.cloudflareDnsAnalyticsTool + const base = { apiKey, zoneId: ' z1 ' } + + it('no longer calls the REST report Cloudflare removes on December 1, 2026', () => { + const url = + typeof tool.request.url === 'function' ? tool.request.url(base as never) : tool.request.url + expect(url).toBe('https://api.cloudflare.com/client/v4/graphql') + expect(url).not.toContain('dns_analytics/report') + expect(tool.request.method).toBe('POST') + }) - it('passes min and max through instead of filling seven zeroes', async () => { - // Cloudflare documents both as "currently always an empty object", so a - // seven-field numeric block is fabricated telemetry. - const out = (await tool.transformResponse!( - envelope({ totals: { queryCount: 12 }, min: {}, max: {}, data: [], rows: 0 }), - {} as never - )) as { output: { min: unknown; max: unknown } } + it('builds a grouped query with the requested dimensions, filters, sort and limit', () => { + const body = tool.request.body!({ + ...base, + since: '2026-10-01T00:00:00Z', + until: '2026-10-07T23:59:59Z', + dimensions: 'queryName,responseCode', + filters: 'queryType==A', + sort: '-queryCount', + limit: 50, + } as never) as { query: string; variables: Record } + + expect(body.variables).toEqual({ zoneTag: 'z1' }) + expect(body.query).toContain('dnsAnalyticsAdaptiveGroups') + expect(body.query).toContain('date_geq: "2026-10-01"') + expect(body.query).toContain('date_leq: "2026-10-07"') + expect(body.query).toContain('queryType: "A"') + expect(body.query).toContain('orderBy: [count_DESC]') + expect(body.query).toContain('limit: 50') + expect(body.query).toContain('dimensions { queryName responseCode }') + }) - expect(out.output.min).toEqual({}) - expect(out.output.max).toEqual({}) + it('refuses metrics and dimensions the GraphQL dataset lacks instead of returning zeros', () => { + const build = tool.request.body! + expect(() => build({ ...base, metrics: 'queryCount,responseTimeAvg' } as never)).toThrow( + /responseTimeAvg/ + ) + expect(() => build({ ...base, dimensions: 'coloName' } as never)).toThrow(/coloName/) }) - it('reports an absent min or max as null rather than zeroes', async () => { - const out = (await tool.transformResponse!(envelope({ totals: {} }), {} as never)) as { - output: { min: unknown; max: unknown } + it('maps grouped rows back onto the report shape existing workflows read', async () => { + const out = (await tool.transformResponse!( + dnsGraphql([ + { count: 7, dimensions: { queryName: 'a.example.com', responseCode: 'NOERROR' } }, + { count: 3, dimensions: { queryName: 'b.example.com', responseCode: 'NXDOMAIN' } }, + ]), + { ...base, dimensions: 'queryName,responseCode' } as never + )) as { + success: boolean + output: { + totals: Record + data: unknown[] + rows: number + min: unknown + max: unknown + } } + expect(out.success).toBe(true) + expect(out.output.totals.queryCount).toBe(10) + expect(out.output.totals.responseTimeAvg).toBeUndefined() + expect(out.output.data).toEqual([ + { dimensions: ['a.example.com', 'NOERROR'], metrics: [7] }, + { dimensions: ['b.example.com', 'NXDOMAIN'], metrics: [3] }, + ]) + expect(out.output.rows).toBe(2) expect(out.output.min).toBeNull() expect(out.output.max).toBeNull() }) - it('leaves an unrequested total absent instead of reading it as zero', async () => { + it('surfaces a GraphQL error message as a failed result', async () => { const out = (await tool.transformResponse!( - envelope({ totals: { queryCount: 12 } }), - {} as never - )) as { output: { totals: Record } } - - expect(out.output.totals.queryCount).toBe(12) - expect(out.output.totals.responseTimeAvg).toBeUndefined() - expect(out.output.totals.uncachedCount).toBeUndefined() + dnsGraphql([], [{ message: 'zone not authorized' }]), + base as never + )) as { success: boolean; error?: string } + expect(out.success).toBe(false) + expect(out.error).toBe('zone not authorized') }) }) diff --git a/apps/sim/tools/cloudflare/dns_analytics.ts b/apps/sim/tools/cloudflare/dns_analytics.ts index b21fe318265..eaebb0de3be 100644 --- a/apps/sim/tools/cloudflare/dns_analytics.ts +++ b/apps/sim/tools/cloudflare/dns_analytics.ts @@ -1,19 +1,153 @@ import type { CloudflareDnsAnalyticsParams, CloudflareDnsAnalyticsResponse, - CloudflareRawDnsAnalyticsReport, + CloudflareRawDnsAnalyticsGraphql, } from '@/tools/cloudflare/types' -import { readCloudflareResponse } from '@/tools/cloudflare/utils' +import { cloudflareHeaders } from '@/tools/cloudflare/utils' import type { ToolConfig } from '@/tools/types' +/** + * Cloudflare removes the REST endpoint `/zones/{zone_id}/dns_analytics/report` on + * December 1, 2026 (https://developers.cloudflare.com/fundamentals/api/reference/deprecations/). + * This tool now reads the `dnsAnalyticsAdaptiveGroups` dataset of the GraphQL Analytics + * API and maps it back onto the report shape, so existing workflows keep their outputs. + * + * The GraphQL dataset exposes a query count grouped by dimensions; the REST report's + * cache and response-time metrics have no documented equivalent there, so asking for + * them is an explicit error rather than a silent zero. + */ +const GRAPHQL_URL = 'https://api.cloudflare.com/client/v4/graphql' +const SUPPORTED_DIMENSIONS = ['queryName', 'queryType', 'responseCode'] as const +type DnsDimension = (typeof SUPPORTED_DIMENSIONS)[number] +const SUPPORTED_METRICS = ['queryCount'] as const +const DEFAULT_WINDOW_DAYS = 7 +const DEFAULT_LIMIT = 100 +const MAX_LIMIT = 10000 + +function splitList(value: string | undefined): string[] { + return (value ?? '') + .split(',') + .map((item) => item.trim()) + .filter(Boolean) +} + +/** Accepts an ISO date/datetime or a REST-style relative offset such as "-6h", "-7d" or "now". */ +export function toGraphqlDate(value: string | undefined, fallback: Date): string { + if (!value || value === 'now') { + return (value ? new Date() : fallback).toISOString().slice(0, 10) + } + const relative = /^-(\d+)([mhd])$/.exec(value.trim()) + if (relative) { + const amount = Number(relative[1]) + const unitMs = { m: 60_000, h: 3_600_000, d: 86_400_000 }[relative[2] as 'm' | 'h' | 'd'] + return new Date(Date.now() - amount * unitMs).toISOString().slice(0, 10) + } + const parsed = new Date(value) + if (Number.isNaN(parsed.getTime())) throw new Error(`Invalid date: ${value}`) + return parsed.toISOString().slice(0, 10) +} + +/** + * REST filters used "field==value" joined by ";" (AND). Only equality on a supported + * dimension has a GraphQL equivalent. + */ +export function toGraphqlFilter(filters: string | undefined): Record { + const out: Record = {} + const clauses = (filters ?? '') + .split(';') + .map((c) => c.trim()) + .filter(Boolean) + for (const clause of clauses) { + const match = /^(\w+)==(.+)$/.exec(clause) + if (!match || !SUPPORTED_DIMENSIONS.includes(match[1] as DnsDimension)) { + throw new Error( + `Unsupported DNS analytics filter "${clause}". Use field==value on ${SUPPORTED_DIMENSIONS.join(', ')}, joined with ";".` + ) + } + out[match[1]] = match[2] + } + return out +} + +/** "-queryCount" / "+queryName" become orderBy values such as count_DESC / queryName_ASC. */ +export function toGraphqlOrderBy(sort: string | undefined): string[] { + return splitList(sort).map((entry) => { + const desc = entry.startsWith('-') + const field = entry.replace(/^[+-]/, '') + const name = field === 'queryCount' ? 'count' : field + if (name !== 'count' && !SUPPORTED_DIMENSIONS.includes(name as DnsDimension)) { + throw new Error(`Unsupported DNS analytics sort field "${field}"`) + } + return `${name}_${desc ? 'DESC' : 'ASC'}` + }) +} + +export function buildDnsAnalyticsQuery(params: CloudflareDnsAnalyticsParams) { + const dimensions = splitList(params.dimensions) + const unsupportedDimensions = dimensions.filter( + (d) => !SUPPORTED_DIMENSIONS.includes(d as DnsDimension) + ) + if (unsupportedDimensions.length > 0) { + throw new Error( + `DNS analytics dimensions not available after Cloudflare's move to GraphQL: ${unsupportedDimensions.join(', ')}. Supported: ${SUPPORTED_DIMENSIONS.join(', ')}.` + ) + } + const metrics = splitList(params.metrics) + const unsupportedMetrics = metrics.filter((m) => !SUPPORTED_METRICS.includes(m as 'queryCount')) + if (unsupportedMetrics.length > 0) { + throw new Error( + `DNS analytics metrics not available after Cloudflare's move to GraphQL: ${unsupportedMetrics.join(', ')}. Supported: queryCount.` + ) + } + + const now = new Date() + const since = toGraphqlDate( + params.since, + new Date(now.getTime() - DEFAULT_WINDOW_DAYS * 86_400_000) + ) + const until = toGraphqlDate(params.until, now) + const filter = { date_geq: since, date_leq: until, ...toGraphqlFilter(params.filters) } + const orderBy = toGraphqlOrderBy(params.sort) + const limit = Math.min(Math.max(Number(params.limit) || DEFAULT_LIMIT, 1), MAX_LIMIT) + const dimensionBlock = dimensions.length > 0 ? ` dimensions { ${dimensions.join(' ')} }` : '' + + // Values are inlined as GraphQL literals (validated above, strings JSON-escaped) so the + // query only declares $zoneTag, whose type Cloudflare documents. + const literal = (value: string) => JSON.stringify(value) + const filterLiteral = Object.entries(filter) + .map(([key, value]) => `${key}: ${literal(String(value))}`) + .join(', ') + const orderLiteral = orderBy.length ? `, orderBy: [${orderBy.join(', ')}]` : '' + const query = `query DnsAnalytics($zoneTag: string!) { + viewer { + zones(filter: { zoneTag: $zoneTag }) { + dnsAnalyticsAdaptiveGroups(filter: { ${filterLiteral} }, limit: ${limit}${orderLiteral}) { count${dimensionBlock} } + } + } +}` + const variables: Record = { zoneTag: params.zoneId.trim() } + return { + query, + variables, + dimensions, + metrics, + since, + until, + filters: params.filters ?? '', + sort: splitList(params.sort), + limit, + } +} + export const dnsAnalyticsTool: ToolConfig< CloudflareDnsAnalyticsParams, CloudflareDnsAnalyticsResponse > = { id: 'cloudflare_dns_analytics', name: 'Cloudflare DNS Analytics', - description: 'Gets DNS analytics report for a zone including query counts and trends.', - version: '1.0.0', + description: + 'Gets DNS analytics for a zone: query counts grouped by query name, type and response code (Cloudflare GraphQL Analytics API).', + version: '2.0.0', params: { zoneId: { @@ -27,41 +161,40 @@ export const dnsAnalyticsTool: ToolConfig< required: false, visibility: 'user-or-llm', description: - 'Start date for analytics (ISO 8601, e.g., "2024-01-01T00:00:00Z") or relative (e.g., "-6h")', + 'Start date (ISO 8601 or relative like "-7d"). The GraphQL dataset filters by day. Default: 7 days ago', }, until: { type: 'string', required: false, visibility: 'user-or-llm', description: - 'End date for analytics (ISO 8601, e.g., "2024-01-31T23:59:59Z") or relative (e.g., "now")', + 'End date (ISO 8601 or "now"). The GraphQL dataset filters by day. Default: today', }, metrics: { type: 'string', required: false, visibility: 'user-or-llm', description: - 'Comma-separated metrics to retrieve (e.g., "queryCount,uncachedCount,staleCount,responseTimeAvg,responseTimeMedian,responseTime90th,responseTime99th"). Optional in the API', + 'Comma-separated metrics. Only "queryCount" is available from the GraphQL DNS analytics dataset. Optional', }, dimensions: { type: 'string', required: false, visibility: 'user-or-llm', - description: - 'Comma-separated dimensions to group by (e.g., "queryName,queryType,responseCode,responseCached,coloName,origin,dayOfWeek,tcp,ipVersion,querySizeBucket,responseSizeBucket")', + description: 'Comma-separated dimensions to group by: queryName, queryType, responseCode', }, filters: { type: 'string', required: false, visibility: 'user-or-llm', - description: 'Filters to apply to the data (e.g., "queryType==A")', + description: + 'Equality filters on a dimension, joined with ";" (e.g., "queryType==A;responseCode==NOERROR")', }, sort: { type: 'string', required: false, visibility: 'user-or-llm', - description: - 'Sort order for the result set. Fields must be included in metrics or dimensions (e.g., "+queryCount" or "-responseTimeAvg")', + description: 'Sort order, e.g. "-queryCount" or "+queryName"', }, limit: { type: 'number', @@ -78,89 +211,56 @@ export const dnsAnalyticsTool: ToolConfig< }, request: { - url: (params) => { - const url = new URL( - `https://api.cloudflare.com/client/v4/zones/${params.zoneId.trim()}/dns_analytics/report` - ) - if (params.since) url.searchParams.append('since', params.since) - if (params.until) url.searchParams.append('until', params.until) - if (params.metrics) url.searchParams.append('metrics', params.metrics) - if (params.dimensions) url.searchParams.append('dimensions', params.dimensions) - if (params.filters) url.searchParams.append('filters', params.filters) - if (params.sort) url.searchParams.append('sort', params.sort) - if (params.limit) url.searchParams.append('limit', String(params.limit)) - return url.toString() + url: () => GRAPHQL_URL, + method: 'POST', + headers: (params) => cloudflareHeaders(params.apiKey), + body: (params) => { + const { query, variables } = buildDnsAnalyticsQuery(params) + return { query, variables } }, - method: 'GET', - headers: (params) => ({ - Authorization: `Bearer ${params.apiKey}`, - 'Content-Type': 'application/json', - }), }, - transformResponse: async (response: Response) => { - const data = await readCloudflareResponse(response) + transformResponse: async (response: Response, params?: CloudflareDnsAnalyticsParams) => { + const data = (await response.json()) as CloudflareRawDnsAnalyticsGraphql + const built = params?.zoneId ? buildDnsAnalyticsQuery(params) : undefined + const query = { + since: built?.since ?? '', + until: built?.until ?? '', + metrics: built?.metrics ?? [], + dimensions: built?.dimensions ?? [], + filters: built?.filters ?? '', + sort: built?.sort ?? [], + limit: built?.limit ?? 0, + } - if (!data.success) { + const error = data.errors?.[0]?.message + if (error || !response.ok) { return { success: false, - output: { - totals: {}, - min: null, - max: null, - data: [], - data_lag: 0, - rows: 0, - query: { - since: '', - until: '', - metrics: [], - dimensions: [], - filters: '', - sort: [], - limit: 0, - }, - }, - error: data.errors?.[0]?.message ?? 'Failed to get DNS analytics', + output: { totals: {}, min: null, max: null, data: [], data_lag: 0, rows: 0, query }, + error: error ?? `Failed to get DNS analytics (HTTP ${response.status})`, } } - const result = data.result + const groups = data.data?.viewer?.zones?.[0]?.dnsAnalyticsAdaptiveGroups ?? [] + const dimensions = query.dimensions as DnsDimension[] + const wantsCount = query.metrics.length === 0 || query.metrics.includes('queryCount') return { success: true, output: { - /** - * Cloudflare only populates the metrics that were requested. Passing the - * block through untouched keeps an unrequested metric absent instead of - * reporting it as a measured zero. - */ - totals: { - queryCount: result?.totals?.queryCount, - uncachedCount: result?.totals?.uncachedCount, - staleCount: result?.totals?.staleCount, - responseTimeAvg: result?.totals?.responseTimeAvg, - responseTimeMedian: result?.totals?.responseTimeMedian, - responseTime90th: result?.totals?.responseTime90th, - responseTime99th: result?.totals?.responseTime99th, - }, - min: result?.min ?? null, - max: result?.max ?? null, - data: - result?.data?.map((entry) => ({ - dimensions: entry.dimensions ?? [], - metrics: entry.metrics ?? [], - })) ?? [], - data_lag: result?.data_lag ?? 0, - rows: result?.rows ?? 0, - query: { - since: result?.query?.since ?? '', - until: result?.query?.until ?? '', - metrics: result?.query?.metrics ?? [], - dimensions: result?.query?.dimensions ?? [], - filters: result?.query?.filters ?? '', - sort: result?.query?.sort ?? [], - limit: result?.query?.limit ?? 0, - }, + /** Only queryCount exists in the GraphQL dataset; other totals stay absent, not zero. */ + totals: wantsCount + ? { queryCount: groups.reduce((sum, g) => sum + (g.count ?? 0), 0) } + : {}, + min: null, + max: null, + data: groups.map((g) => ({ + dimensions: dimensions.map((d) => String(g.dimensions?.[d] ?? '')), + metrics: wantsCount ? [g.count ?? 0] : [], + })), + data_lag: 0, + rows: groups.length, + query, }, } }, diff --git a/apps/sim/tools/cloudflare/types.ts b/apps/sim/tools/cloudflare/types.ts index 88824b2cf80..915d4938638 100644 --- a/apps/sim/tools/cloudflare/types.ts +++ b/apps/sim/tools/cloudflare/types.ts @@ -306,6 +306,21 @@ interface CloudflareRawDnsAnalyticsAggregate { responseTime99th?: number } +/** Raw GraphQL Analytics response for the `dnsAnalyticsAdaptiveGroups` dataset. */ +export interface CloudflareRawDnsAnalyticsGraphql { + data?: { + viewer?: { + zones?: Array<{ + dnsAnalyticsAdaptiveGroups?: Array<{ + count?: number + dimensions?: Partial> + }> + }> + } + } | null + errors?: Array<{ message?: string }> | null +} + /** Raw DNS analytics report payload. */ export interface CloudflareRawDnsAnalyticsReport { totals?: CloudflareRawDnsAnalyticsAggregate