Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 7 additions & 24 deletions apps/sim/blocks/blocks/cloudflare.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")...',
Expand All @@ -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")...',
Expand All @@ -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.`,
Expand Down
101 changes: 80 additions & 21 deletions apps/sim/tools/cloudflare/cloudflare.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> }>,
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<string, unknown> }

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<string, unknown>
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<string, unknown> } }

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')
})
})

Expand Down
Loading