feat: free tier zone handling + metric aggregation

- Skip free tier zones for GraphQL analytics queries (no API access)
- Add cloudflare_zones_skipped_free_tier gauge
- Aggregate duplicate label combos (sum counters, max gauges)
- Wrap GraphQL errors with context
- Hide config UI when DISABLE_CONFIG_API=true
- Document free tier limitations
This commit is contained in:
dmmulroy
2025-12-10 00:41:42 -05:00
parent e64d93a520
commit 1911da0b0f
10 changed files with 247 additions and 18 deletions

View File

@@ -305,6 +305,22 @@ curl -X DELETE https://your-worker.workers.dev/config
| `cloudflare_zones_total` | gauge | - |
| `cloudflare_zones_filtered` | gauge | - |
| `cloudflare_zones_processed` | gauge | - |
| `cloudflare_zones_skipped_free_tier` | gauge | - |
## Free Tier Zone Limitations
Zones on Cloudflare's Free plan don't have access to the GraphQL Analytics API. The exporter automatically detects and skips free tier zones for metrics that require this API.
**Free tier zones still export:**
- `cloudflare_zone_certificate_validation_status` (SSL certificates)
- `cloudflare_zone_lb_origin_weight` (Load balancer weights, if configured)
**Monitor skipped zones:**
```
cloudflare_zones_skipped_free_tier
```
For mixed accounts (enterprise + free zones), only free zones are skipped—paid zones continue to export all metrics.
## Architecture

View File

@@ -840,8 +840,21 @@ export class CloudflareMetricsClient {
result = await this.gql.query(HTTPMetricsQueryNoBots, queryVars);
}
// Safety net: free tier zones should be filtered upstream, but handle gracefully
if (result.error?.message.includes("does not have access to the path")) {
this.logger.error(
"Zone(s) lack GraphQL analytics access - ensure free tier zones are filtered",
{ error: result.error.message },
);
return [];
}
if (result.error) {
throw new Error(`GraphQL error: ${result.error.message}`);
throw new GraphQLError(
`GraphQL error: ${result.error.message}`,
result.error.graphQLErrors ?? [],
{ context: { query: "http-metrics", zone_count: zoneIds.length } },
);
}
// Initialize all metric definitions

View File

@@ -102,3 +102,38 @@ export const FREE_TIER_QUERIES = [
* Type for free tier query names.
*/
export type FreeTierQuery = (typeof FREE_TIER_QUERIES)[number];
/**
* Zone-level GraphQL queries that require paid tier.
* Free tier zones don't have access to adaptive analytics endpoints.
*/
export const PAID_TIER_GRAPHQL_QUERIES = [
"http-metrics",
"adaptive-metrics",
"edge-country-metrics",
"colo-metrics",
"colo-error-metrics",
"request-method-metrics",
"health-check-metrics",
"load-balancer-metrics",
"logpush-zone",
"origin-status-metrics",
"cache-miss-metrics",
] as const;
/**
* Type for paid tier GraphQL query names.
*/
export type PaidTierGraphQLQuery = (typeof PAID_TIER_GRAPHQL_QUERIES)[number];
/**
* Type guard for paid tier GraphQL queries.
*
* @param query Query name to check.
* @returns True if query requires paid tier.
*/
export function isPaidTierGraphQLQuery(
query: string,
): query is PaidTierGraphQLQuery {
return (PAID_TIER_GRAPHQL_QUERIES as readonly string[]).includes(query);
}

View File

@@ -441,6 +441,7 @@ export const LandingPage: FC<Props> = ({ config }) => {
</p>
</div>
{/* Runtime Configuration Card */}
{!config.disableConfigApi && (
<div class="bg-white rounded-2xl border border-gray-200 shadow-sm mb-12 overflow-hidden">
{/* Header with Save/Reset buttons */}
<div class="flex items-center justify-between p-6 border-b border-gray-100">
@@ -455,8 +456,9 @@ export const LandingPage: FC<Props> = ({ config }) => {
<div class="flex gap-2">
<button
type="button"
id="reset-all-btn"
onclick="resetAllConfig()"
class="px-4 py-2 text-sm text-gray-600 hover:text-gray-900 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
class="px-4 py-2 text-sm text-gray-600 hover:text-gray-900 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
Reset All
</button>
@@ -1121,6 +1123,7 @@ export const LandingPage: FC<Props> = ({ config }) => {
</div>
</div>
</div>
)}
{/* Toast notification */}
<div id="toast" class="toast" /> {/* Live Metrics Section */}
<h2 class="text-2xl font-bold text-gray-900 mb-6">Live Metrics</h2>
@@ -1184,7 +1187,7 @@ export const LandingPage: FC<Props> = ({ config }) => {
</div>
</div>
<LandingPageScript metricsPath={config.metricsPath} />
<LandingPageScript metricsPath={config.metricsPath} disableConfigApi={config.disableConfigApi} />
</body>
</html>
);

View File

@@ -1,11 +1,14 @@
import { html } from "hono/html";
import type { FC } from "hono/jsx";
type Props = { metricsPath: string };
type Props = { metricsPath: string; disableConfigApi: boolean };
export const LandingPageScript: FC<Props> = ({ metricsPath }) => {
export const LandingPageScript: FC<Props> = ({ metricsPath, disableConfigApi }) => {
return html`
<script>
// Config API disabled flag
const configApiDisabled = ${String(disableConfigApi)};
// Config state management
let serverConfig = {};
let localConfig = {};
@@ -22,6 +25,7 @@ export const LandingPageScript: FC<Props> = ({ metricsPath }) => {
// Load config on page load
async function loadConfig() {
if (configApiDisabled) return;
try {
const [configRes, defaultsRes] = await Promise.all([
fetch('/config'),

View File

@@ -5,7 +5,11 @@ import {
ZONE_LEVEL_QUERIES,
} from "../cloudflare/client";
import { FREE_TIER_QUERIES } from "../cloudflare/queries";
import { filterZonesByIds, parseCommaSeparated } from "../lib/filters";
import {
filterZonesByIds,
isFreeTierZone,
parseCommaSeparated,
} from "../lib/filters";
import { createLogger, type Logger } from "../lib/logger";
import type { MetricDefinition } from "../lib/metrics";
import { getConfig, type ResolvedConfig } from "../lib/runtime-config";
@@ -304,7 +308,12 @@ export class AccountMetricCoordinator extends DurableObject<Env> {
*/
async export(): Promise<{
metrics: MetricDefinition[];
zoneCounts: { total: number; filtered: number; processed: number };
zoneCounts: {
total: number;
filtered: number;
processed: number;
skippedFreeTier: number;
};
}> {
const config = await getConfig(this.env);
const logger = this.createLogger(config);
@@ -383,10 +392,20 @@ export class AccountMetricCoordinator extends DurableObject<Env> {
const allMetrics = [...accountMetricsResults, ...zoneMetricsResults].flat();
// Count processed zones (zones with at least one metric result)
const processedZones = zoneMetricsResults.filter(
(r) => r.length > 0,
).length;
// Count unique zones with metrics from all results
const zonesWithMetrics = new Set<string>();
for (const metric of allMetrics) {
for (const v of metric.values) {
const zone = v.labels.zone;
if (zone) {
zonesWithMetrics.add(zone);
}
}
}
const processedZones = zonesWithMetrics.size;
// Count free tier zones
const freeTierCount = state.zones.filter(isFreeTierZone).length;
return {
metrics: allMetrics,
@@ -394,6 +413,7 @@ export class AccountMetricCoordinator extends DurableObject<Env> {
total: state.totalZoneCount,
filtered: state.zones.length,
processed: processedZones,
skippedFreeTier: freeTierCount,
},
};
}

View File

@@ -189,20 +189,31 @@ export class MetricCoordinator extends DurableObject<Env> {
return {
metrics: [],
zoneCounts: { total: 0, filtered: 0, processed: 0 },
zoneCounts: {
total: 0,
filtered: 0,
processed: 0,
skippedFreeTier: 0,
},
};
}
}),
);
// Aggregate stats
const zoneCounts = { total: 0, filtered: 0, processed: 0 };
const zoneCounts = {
total: 0,
filtered: 0,
processed: 0,
skippedFreeTier: 0,
};
const allMetrics: MetricDefinition[] = [];
for (const result of results) {
allMetrics.push(...result.metrics);
zoneCounts.total += result.zoneCounts.total;
zoneCounts.filtered += result.zoneCounts.filtered;
zoneCounts.processed += result.zoneCounts.processed;
zoneCounts.skippedFreeTier += result.zoneCounts.skippedFreeTier;
}
// Add exporter info metrics
@@ -223,13 +234,18 @@ export class MetricCoordinator extends DurableObject<Env> {
* Builds exporter health and discovery metrics.
*
* @param accountCount Number of accounts discovered.
* @param zoneCounts Zone counts (total, filtered, processed).
* @param zoneCounts Zone counts (total, filtered, processed, skippedFreeTier).
* @param errorsByAccount Errors by account and error code.
* @returns Exporter info metrics.
*/
private buildExporterInfoMetrics(
accountCount: number,
zoneCounts: { total: number; filtered: number; processed: number },
zoneCounts: {
total: number;
filtered: number;
processed: number;
skippedFreeTier: number;
},
errorsByAccount: Map<string, { code: string; count: number }[]>,
): MetricDefinition[] {
const metrics: MetricDefinition[] = [
@@ -263,6 +279,12 @@ export class MetricCoordinator extends DurableObject<Env> {
type: "gauge",
values: [{ labels: {}, value: zoneCounts.processed }],
},
{
name: "cloudflare_zones_skipped_free_tier",
help: "Zones skipped due to free tier plan (no GraphQL analytics access)",
type: "gauge",
values: [{ labels: {}, value: zoneCounts.skippedFreeTier }],
},
];
// Add error metrics if any errors occurred

View File

@@ -4,6 +4,8 @@ import {
isAccountLevelQuery,
isZoneLevelQuery,
} from "../cloudflare/client";
import { isPaidTierGraphQLQuery } from "../cloudflare/queries";
import { partitionZonesByTier } from "../lib/filters";
import { createLogger, type Logger } from "../lib/logger";
import type { MetricDefinition, MetricValue } from "../lib/metrics";
import { getConfig, type ResolvedConfig } from "../lib/runtime-config";
@@ -313,6 +315,7 @@ export class MetricExporter extends DurableObject<Env> {
client,
state,
timeRange,
logger,
);
} else {
metrics = await this.fetchZoneScopedMetrics(client, state);
@@ -365,12 +368,14 @@ export class MetricExporter extends DurableObject<Env> {
* @param client Cloudflare metrics client.
* @param state Current exporter state.
* @param timeRange Time range for metrics queries.
* @param logger Logger instance.
* @returns Array of metric definitions.
*/
private async fetchAccountScopedMetrics(
client: ReturnType<typeof getCloudflareMetricsClient>,
state: MetricExporterState,
timeRange: TimeRange,
logger: Logger,
): Promise<MetricDefinition[]> {
const { queryName, accountId, accountName, zones, firewallRules } = state;
@@ -386,11 +391,31 @@ export class MetricExporter extends DurableObject<Env> {
// Zone-batched queries - fetch all zones in one GraphQL call
if (isZoneLevelQuery(queryName)) {
const zoneIds = zones.map((z) => z.id);
// Filter out free tier zones for paid-tier GraphQL queries
let zonesToQuery = zones;
if (isPaidTierGraphQLQuery(queryName)) {
const { paid, free } = partitionZonesByTier(zones);
if (free.length > 0) {
logger.info("Skipping free tier zones for paid-tier query", {
skipped_zones: free.map((z) => z.name),
processing_zones: paid.length,
});
}
zonesToQuery = paid;
if (zonesToQuery.length === 0) {
logger.info("No paid tier zones to query");
return [];
}
}
const zoneIds = zonesToQuery.map((z) => z.id);
return client.getZoneMetrics(
queryName,
zoneIds,
zones,
zonesToQuery,
firewallRules,
timeRange,
);

View File

@@ -56,3 +56,41 @@ export function filterZonesByIds(
export function findZoneName(zoneId: string, zones: Zone[]): string {
return zones.find((z) => z.id === zoneId)?.name ?? zoneId;
}
/**
* Plan ID for Cloudflare Free tier.
* Free tier zones don't have access to GraphQL Analytics API.
*/
export const FREE_PLAN_ID = "0feeeeeeeeeeeeeeeeeeeeeeeeeeeeee";
/**
* Check if zone is on Free tier.
*
* @param zone Zone to check.
* @returns True if zone is on Free tier.
*/
export function isFreeTierZone(zone: Zone): boolean {
return zone.plan.id === FREE_PLAN_ID;
}
/**
* Partition zones into paid and free tier.
*
* @param zones Array of zones to partition.
* @returns Object with paid and free zone arrays.
*/
export function partitionZonesByTier(zones: Zone[]): {
paid: Zone[];
free: Zone[];
} {
const paid: Zone[] = [];
const free: Zone[] = [];
for (const zone of zones) {
if (isFreeTierZone(zone)) {
free.push(zone);
} else {
paid.push(zone);
}
}
return { paid, free };
}

View File

@@ -13,6 +13,7 @@ export type SerializeOptions = {
/**
* Serializes MetricDefinition array to Prometheus text exposition format.
* Groups metrics by name, outputs HELP/TYPE headers, then values.
* Aggregates duplicate label combinations (sum for counters, max for gauges).
*
* @param metrics Array of metric definitions to serialize.
* @param options Serialization options for filtering.
@@ -63,8 +64,11 @@ export function serializeToPrometheus(
// TYPE line
lines.push(`# TYPE ${name} ${metric.type}`);
// Aggregate values by label signature to eliminate duplicates
const aggregated = aggregateByLabels(metric.values, metric.type);
// Value lines
for (const { labels, value } of metric.values) {
for (const { labels, value } of aggregated) {
const labelStr = formatLabels(labels);
lines.push(`${name}${labelStr} ${formatValue(value)}`);
}
@@ -76,6 +80,55 @@ export function serializeToPrometheus(
return lines.join("\n");
}
/**
* Aggregates metric values with identical labels.
* Counters are summed; gauges take the maximum value.
*
* @param values Array of metric values to aggregate.
* @param type Metric type (counter, gauge, etc.).
* @returns Deduplicated array of metric values.
*/
function aggregateByLabels(
values: readonly { labels: Record<string, string>; value: number }[],
type: string,
): { labels: Record<string, string>; value: number }[] {
const bySignature = new Map<
string,
{ labels: Record<string, string>; value: number }
>();
for (const { labels, value } of values) {
const sig = labelSignature(labels);
const existing = bySignature.get(sig);
if (existing) {
if (type === "counter") {
existing.value += value;
} else {
// For gauges (including percentiles), take max as upper bound
existing.value = Math.max(existing.value, value);
}
} else {
bySignature.set(sig, { labels, value });
}
}
return [...bySignature.values()];
}
/**
* Creates stable signature from labels for deduplication.
*
* @param labels Label key-value pairs.
* @returns Stable string signature for comparison.
*/
function labelSignature(labels: Record<string, string>): string {
return Object.entries(labels)
.sort(([a], [b]) => a.localeCompare(b))
.map(([k, v]) => `${k}\x00${v}`)
.join("\x01");
}
/**
* Filters out excluded label keys from a labels object.
*