mirror of
https://github.com/MrUnknownDE/cloudflare-prometheus-exporter.git
synced 2026-04-18 22:03:45 +02:00
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:
16
README.md
16
README.md
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user