├── src ├── env.d.ts ├── cloudflare │ ├── gql │ │ ├── client.ts │ │ └── queries.ts │ └── queries.ts ├── lib │ ├── metrics.ts │ ├── time.ts │ ├── filters.ts │ ├── config.ts │ ├── health.ts │ ├── types.ts │ ├── prometheus.ts │ ├── logger.ts │ ├── errors.ts │ └── runtime-config.ts ├── worker.tsx ├── durable-objects │ ├── MetricCoordinator.ts │ ├── AccountMetricCoordinator.ts │ └── MetricExporter.ts └── components │ └── LandingPageScript.tsx ├── docker-compose.yml ├── prometheus.yml ├── .dockerignore ├── package.json ├── biome.json ├── LICENSE ├── Dockerfile ├── tsconfig.json ├── wrangler.jsonc ├── .gitignore ├── README.md └── bun.lock /src/env.d.ts: -------------------------------------------------------------------------------- 1 | declare namespace Cloudflare { 2 | interface Env { 3 | CLOUDFLARE_API_TOKEN: string; 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | prometheus: 3 | image: prom/prometheus:latest 4 | ports: 5 | - "9090:9090" 6 | volumes: 7 | - ./prometheus.yml:/etc/prometheus/prometheus.yml 8 | extra_hosts: 9 | - "host.docker.internal:host-gateway" 10 | -------------------------------------------------------------------------------- /prometheus.yml: -------------------------------------------------------------------------------- 1 | global: 2 | scrape_interval: 60s 3 | evaluation_interval: 60s 4 | 5 | scrape_configs: 6 | - job_name: "cloudflare-exporter" 7 | static_configs: 8 | - targets: ["host.docker.internal:8787"] 9 | metrics_path: /metrics 10 | scrape_timeout: 30s 11 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | # Dependencies 2 | node_modules 3 | 4 | # Build outputs 5 | dist 6 | .wrangler 7 | 8 | # Git 9 | .git 10 | .gitignore 11 | 12 | # IDE 13 | .idea 14 | .vscode 15 | *.swp 16 | *.swo 17 | 18 | # Docker 19 | Dockerfile 20 | docker-compose.yml 21 | .dockerignore 22 | 23 | # Documentation 24 | README.md 25 | LICENSE 26 | 27 | # Local config 28 | .dev.vars 29 | .env 30 | .env.* 31 | 32 | 33 | -------------------------------------------------------------------------------- /src/cloudflare/gql/client.ts: -------------------------------------------------------------------------------- 1 | import { initGraphQLTada } from "gql.tada"; 2 | import type { introspection } from "./graphql-env"; 3 | 4 | export const graphql = initGraphQLTada<{ 5 | introspection: introspection; 6 | scalars: { 7 | Date: string; 8 | DateTime: string; 9 | Time: string; 10 | bytes: string; 11 | float32: number; 12 | float64: number; 13 | string: string; 14 | uint8: number; 15 | uint16: number; 16 | uint32: number; 17 | uint64: number; 18 | }; 19 | }>(); 20 | 21 | export type { FragmentOf, ResultOf, VariablesOf } from "gql.tada"; 22 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cloudflare-prometheus-exporter", 3 | "version": "0.0.1", 4 | "private": true, 5 | "scripts": { 6 | "deploy": "wrangler deploy", 7 | "dev": "wrangler dev", 8 | "start": "wrangler dev", 9 | "cf-typegen": "wrangler types", 10 | "gql:generate": "gql.tada generate output", 11 | "check": "biome check", 12 | "format": "biome format --write", 13 | "lint": "biome lint" 14 | }, 15 | "devDependencies": { 16 | "@biomejs/biome": "^2.3.8", 17 | "typescript": "^5.9.3", 18 | "wrangler": "^4.51.0" 19 | }, 20 | "dependencies": { 21 | "@urql/core": "^6.0.1", 22 | "cloudflare": "^5.2.0", 23 | "consola": "^3.4.2", 24 | "dataloader": "^2.2.3", 25 | "gql.tada": "^1.9.0", 26 | "graphql": "^16.12.0", 27 | "hono": "^4.10.7", 28 | "install": "^0.13.0", 29 | "zod": "^4.1.13" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /biome.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://biomejs.dev/schemas/2.3.8/schema.json", 3 | "vcs": { 4 | "enabled": true, 5 | "clientKind": "git", 6 | "useIgnoreFile": true 7 | }, 8 | "files": { 9 | "ignoreUnknown": false, 10 | "includes": [ 11 | "**", 12 | "!worker-configuration.d.ts", 13 | "!src/gql/graphql-env.d.ts", 14 | "!src/gql/schema.gql", 15 | "!src/cloudflare/gql/graphql-env.d.ts", 16 | "!src/cloudflare/gql/schema.gql" 17 | ] 18 | }, 19 | "formatter": { 20 | "enabled": true, 21 | "indentStyle": "tab" 22 | }, 23 | "linter": { 24 | "enabled": true, 25 | "rules": { 26 | "recommended": true 27 | } 28 | }, 29 | "javascript": { 30 | "formatter": { 31 | "quoteStyle": "double" 32 | } 33 | }, 34 | "assist": { 35 | "enabled": true, 36 | "actions": { 37 | "source": { 38 | "organizeImports": "on" 39 | } 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/lib/metrics.ts: -------------------------------------------------------------------------------- 1 | import z from "zod"; 2 | 3 | /** 4 | * Prometheus metric type discriminator. 5 | */ 6 | export type MetricType = z.infer; 7 | 8 | /** 9 | * Zod schema validating Prometheus metric types (counter or gauge). 10 | */ 11 | export const MetricTypeSchema = z.union([ 12 | z.literal("counter"), 13 | z.literal("gauge"), 14 | ]); 15 | 16 | /** 17 | * Single metric observation with labels and numeric value. 18 | */ 19 | export type MetricValue = z.infer; 20 | 21 | /** 22 | * Zod schema validating metric observations with label key-value pairs and numeric values. 23 | */ 24 | export const MetricValueSchema = z.object({ 25 | labels: z.record(z.string(), z.string()), 26 | value: z.number(), 27 | }); 28 | 29 | /** 30 | * Complete metric definition with metadata and observations for Prometheus export. 31 | */ 32 | export type MetricDefinition = z.infer; 33 | 34 | /** 35 | * Zod schema validating complete metric definitions including name, help text, type, and observations. 36 | */ 37 | export const MetricDefinitionSchema = z.object({ 38 | name: z.string(), 39 | help: z.string(), 40 | type: MetricTypeSchema, 41 | values: z.array(MetricValueSchema), 42 | }); 43 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Stage 1: Install dependencies with bun 2 | FROM oven/bun:1 AS deps 3 | 4 | WORKDIR /app 5 | 6 | # Copy package files 7 | COPY package.json bun.lock ./ 8 | 9 | # Install dependencies using bun 10 | RUN bun install --frozen-lockfile 11 | 12 | # Stage 2: Runtime with Node.js LTS 13 | FROM node:24-slim AS runtime 14 | 15 | WORKDIR /app 16 | 17 | # Install CA certificates for TLS and wrangler globally 18 | RUN apt-get update \ 19 | && apt-get install -y --no-install-recommends ca-certificates \ 20 | && rm -rf /var/lib/apt/lists/* \ 21 | && npm install -g wrangler 22 | 23 | # Copy dependencies from bun stage 24 | COPY --from=deps /app/node_modules ./node_modules 25 | 26 | # Copy application source 27 | COPY package.json wrangler.jsonc tsconfig.json ./ 28 | COPY src ./src 29 | 30 | # Expose the default wrangler dev port 31 | EXPOSE 8787 32 | 33 | # Create entrypoint script that generates .dev.vars from environment variables 34 | # Wrangler expects secrets in .dev.vars file, not shell env vars 35 | RUN printf '#!/bin/sh\n\ 36 | # Generate .dev.vars from environment variables\n\ 37 | : > .dev.vars\n\ 38 | env | grep -E "^(CLOUDFLARE_|CF_)" | while read -r line; do\n\ 39 | echo "$line" >> .dev.vars\n\ 40 | done\n\ 41 | exec wrangler dev --local --ip 0.0.0.0 "$@"\n' > /app/entrypoint.sh \ 42 | && chmod +x /app/entrypoint.sh 43 | 44 | ENTRYPOINT ["/app/entrypoint.sh"] 45 | -------------------------------------------------------------------------------- /src/lib/time.ts: -------------------------------------------------------------------------------- 1 | import type { TimeRange } from "./types"; 2 | 3 | /** 4 | * Computes time range for GraphQL queries with delay and window. 5 | * Rounds to nearest minute and applies delay to account for ingestion lag. 6 | * 7 | * @param scrapeDelaySeconds Delay in seconds to account for ingestion lag. 8 | * @param timeWindowSeconds Window size in seconds for the time range. 9 | * @returns Time range with mintime and maxtime ISO strings. 10 | */ 11 | export function getTimeRange( 12 | scrapeDelaySeconds: number = 300, 13 | timeWindowSeconds: number = 60, 14 | ): TimeRange { 15 | const now = new Date(); 16 | now.setSeconds(0, 0); 17 | now.setTime(now.getTime() - scrapeDelaySeconds * 1000); 18 | const maxtime = now.toISOString(); 19 | now.setTime(now.getTime() - timeWindowSeconds * 1000); 20 | const mintime = now.toISOString(); 21 | return { mintime, maxtime }; 22 | } 23 | 24 | /** 25 | * Generates deterministic metric key from name and labels. 26 | * Labels are sorted alphabetically for consistency. 27 | * 28 | * @param name Metric name. 29 | * @param labels Label key value pairs. 30 | * @returns Formatted metric key string. 31 | */ 32 | export function metricKey( 33 | name: string, 34 | labels: Record, 35 | ): string { 36 | const sortedLabels = Object.entries(labels) 37 | .sort(([a], [b]) => a.localeCompare(b)) 38 | .map(([k, v]) => `${k}=${v}`) 39 | .join(","); 40 | return `${name}{${sortedLabels}}`; 41 | } 42 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | // Environment setup & latest features 4 | "lib": ["ESNext"], 5 | "target": "ESNext", 6 | "module": "Preserve", 7 | "moduleDetection": "force", 8 | "jsx": "react-jsx", 9 | "jsxImportSource": "hono/jsx", 10 | "allowJs": true, 11 | // Bundler mode 12 | "moduleResolution": "bundler", 13 | "allowImportingTsExtensions": true, 14 | "verbatimModuleSyntax": true, 15 | "noEmit": true, 16 | // Best practices 17 | "strict": true, 18 | "skipLibCheck": true, 19 | "noFallthroughCasesInSwitch": true, 20 | "noUncheckedIndexedAccess": true, 21 | "noImplicitOverride": true, 22 | // Some stricter flags (disabled by default) 23 | "noUnusedLocals": false, 24 | "noUnusedParameters": false, 25 | "noPropertyAccessFromIndexSignature": false, 26 | /* Enable importing .json files */ 27 | "resolveJsonModule": true, 28 | /* Enable error reporting in type-checked JavaScript files. */ 29 | "checkJs": false, 30 | /* Ensure that each file can be safely transpiled without relying on other imports. */ 31 | "isolatedModules": true, 32 | /* Allow 'import x from y' when a module doesn't have a default export. */ 33 | "allowSyntheticDefaultImports": true, 34 | /* Ensure that casing is correct in imports. */ 35 | "forceConsistentCasingInFileNames": true, 36 | "types": ["./worker-configuration.d.ts"], 37 | /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */ 38 | "plugins": [ 39 | { 40 | "name": "gql.tada/ts-plugin", 41 | "schema": "./src/gql/schema.gql", 42 | "tadaOutputLocation": "./src/gql/graphql-env.d.ts" 43 | } 44 | ] 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /wrangler.jsonc: -------------------------------------------------------------------------------- 1 | /** 2 | * For more details on how to configure Wrangler, refer to: 3 | * https://developers.cloudflare.com/workers/wrangler/configuration/ 4 | */ 5 | { 6 | "$schema": "node_modules/wrangler/config-schema.json", 7 | "name": "cloudflare-prometheus-exporter", 8 | "main": "src/worker.tsx", 9 | "compatibility_date": "2025-12-09", 10 | "compatibility_flags": ["nodejs_compat"], 11 | "migrations": [ 12 | { 13 | "tag": "v1", 14 | "new_sqlite_classes": [ 15 | "MetricExporter", 16 | "MetricCoordinator", 17 | "AccountMetricCoordinator" 18 | ] 19 | } 20 | ], 21 | "durable_objects": { 22 | "bindings": [ 23 | { 24 | "name": "MetricCoordinator", 25 | "class_name": "MetricCoordinator" 26 | }, 27 | { 28 | "name": "AccountMetricCoordinator", 29 | "class_name": "AccountMetricCoordinator" 30 | }, 31 | { 32 | "name": "MetricExporter", 33 | "class_name": "MetricExporter" 34 | } 35 | ] 36 | }, 37 | "observability": { 38 | "enabled": true 39 | }, 40 | "kv_namespaces": [ 41 | { 42 | "binding": "CONFIG_KV" 43 | } 44 | ], 45 | "ratelimits": [ 46 | { 47 | "name": "CF_API_RATE_LIMITER", 48 | "namespace_id": "1", 49 | "simple": { 50 | "limit": 40, 51 | "period": 10 52 | } 53 | } 54 | ], 55 | "vars": { 56 | "QUERY_LIMIT": 10000, 57 | "SCRAPE_DELAY_SECONDS": 300, 58 | "TIME_WINDOW_SECONDS": 60, 59 | "METRIC_REFRESH_INTERVAL_SECONDS": 60, 60 | "LOG_FORMAT": "json", 61 | "LOG_LEVEL": "info", 62 | "ACCOUNT_LIST_CACHE_TTL_SECONDS": 600, 63 | "ZONE_LIST_CACHE_TTL_SECONDS": 1800, 64 | "SSL_CERTS_CACHE_TTL_SECONDS": 1800, 65 | "EXCLUDE_HOST": false, 66 | "CF_HTTP_STATUS_GROUP": false, 67 | "METRICS_PATH": "/metrics", 68 | "DISABLE_UI": false, 69 | "DISABLE_CONFIG_API": false 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/lib/filters.ts: -------------------------------------------------------------------------------- 1 | import type { Account, Zone } from "./types"; 2 | 3 | /** 4 | * Parses comma-separated string into Set, trimming whitespace. 5 | * 6 | * @param value Comma-separated string to parse. 7 | * @returns Set of trimmed non-empty strings, or empty Set for empty/undefined input. 8 | */ 9 | export function parseCommaSeparated(value: string | undefined): Set { 10 | if (!value || value.trim() === "") { 11 | return new Set(); 12 | } 13 | return new Set( 14 | value 15 | .split(",") 16 | .map((s) => s.trim()) 17 | .filter((s) => s.length > 0), 18 | ); 19 | } 20 | 21 | /** 22 | * Filters accounts to only include those with IDs in the set. 23 | * 24 | * @param accounts Array of accounts to filter. 25 | * @param includeIds Set of account IDs to include. 26 | * @returns Filtered array of accounts. 27 | */ 28 | export function filterAccountsByIds( 29 | accounts: Account[], 30 | includeIds: ReadonlySet, 31 | ): Account[] { 32 | return accounts.filter((a) => includeIds.has(a.id)); 33 | } 34 | 35 | /** 36 | * Filters zones to only include those with IDs in the set. 37 | * 38 | * @param zones Array of zones to filter. 39 | * @param includeIds Set of zone IDs to include. 40 | * @returns Filtered array of zones. 41 | */ 42 | export function filterZonesByIds( 43 | zones: Zone[], 44 | includeIds: ReadonlySet, 45 | ): Zone[] { 46 | return zones.filter((z) => includeIds.has(z.id)); 47 | } 48 | 49 | /** 50 | * Looks up zone name by ID, falling back to ID if not found. 51 | * 52 | * @param zoneId Zone ID to look up. 53 | * @param zones Array of zones to search. 54 | * @returns Zone name if found, otherwise the zone ID. 55 | */ 56 | export function findZoneName(zoneId: string, zones: Zone[]): string { 57 | return zones.find((z) => z.id === zoneId)?.name ?? zoneId; 58 | } 59 | 60 | /** 61 | * Plan ID for Cloudflare Free tier. 62 | * Free tier zones don't have access to GraphQL Analytics API. 63 | */ 64 | export const FREE_PLAN_ID = "0feeeeeeeeeeeeeeeeeeeeeeeeeeeeee"; 65 | 66 | /** 67 | * Check if zone is on Free tier. 68 | * 69 | * @param zone Zone to check. 70 | * @returns True if zone is on Free tier. 71 | */ 72 | export function isFreeTierZone(zone: Zone): boolean { 73 | return zone.plan.id === FREE_PLAN_ID; 74 | } 75 | 76 | /** 77 | * Partition zones into paid and free tier. 78 | * 79 | * @param zones Array of zones to partition. 80 | * @returns Object with paid and free zone arrays. 81 | */ 82 | export function partitionZonesByTier(zones: Zone[]): { 83 | paid: Zone[]; 84 | free: Zone[]; 85 | } { 86 | const paid: Zone[] = []; 87 | const free: Zone[] = []; 88 | for (const zone of zones) { 89 | if (isFreeTierZone(zone)) { 90 | free.push(zone); 91 | } else { 92 | paid.push(zone); 93 | } 94 | } 95 | return { paid, free }; 96 | } 97 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | 3 | logs 4 | _.log 5 | npm-debug.log_ 6 | yarn-debug.log* 7 | yarn-error.log* 8 | lerna-debug.log* 9 | .pnpm-debug.log* 10 | 11 | # Diagnostic reports (https://nodejs.org/api/report.html) 12 | 13 | report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json 14 | 15 | # Runtime data 16 | 17 | pids 18 | _.pid 19 | _.seed 20 | \*.pid.lock 21 | 22 | # Directory for instrumented libs generated by jscoverage/JSCover 23 | 24 | lib-cov 25 | 26 | # Coverage directory used by tools like istanbul 27 | 28 | coverage 29 | \*.lcov 30 | 31 | # nyc test coverage 32 | 33 | .nyc_output 34 | 35 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 36 | 37 | .grunt 38 | 39 | # Bower dependency directory (https://bower.io/) 40 | 41 | bower_components 42 | 43 | # node-waf configuration 44 | 45 | .lock-wscript 46 | 47 | # Compiled binary addons (https://nodejs.org/api/addons.html) 48 | 49 | build/Release 50 | 51 | # Dependency directories 52 | 53 | node_modules/ 54 | jspm_packages/ 55 | 56 | # Snowpack dependency directory (https://snowpack.dev/) 57 | 58 | web_modules/ 59 | 60 | # TypeScript cache 61 | 62 | \*.tsbuildinfo 63 | 64 | # Optional npm cache directory 65 | 66 | .npm 67 | 68 | # Optional eslint cache 69 | 70 | .eslintcache 71 | 72 | # Optional stylelint cache 73 | 74 | .stylelintcache 75 | 76 | # Microbundle cache 77 | 78 | .rpt2_cache/ 79 | .rts2_cache_cjs/ 80 | .rts2_cache_es/ 81 | .rts2_cache_umd/ 82 | 83 | # Optional REPL history 84 | 85 | .node_repl_history 86 | 87 | # Output of 'npm pack' 88 | 89 | \*.tgz 90 | 91 | # Yarn Integrity file 92 | 93 | .yarn-integrity 94 | 95 | # parcel-bundler cache (https://parceljs.org/) 96 | 97 | .cache 98 | .parcel-cache 99 | 100 | # Next.js build output 101 | 102 | .next 103 | out 104 | 105 | # Nuxt.js build / generate output 106 | 107 | .nuxt 108 | dist 109 | 110 | # Gatsby files 111 | 112 | .cache/ 113 | 114 | # Comment in the public line in if your project uses Gatsby and not Next.js 115 | 116 | # https://nextjs.org/blog/next-9-1#public-directory-support 117 | 118 | # public 119 | 120 | # vuepress build output 121 | 122 | .vuepress/dist 123 | 124 | # vuepress v2.x temp and cache directory 125 | 126 | .temp 127 | .cache 128 | 129 | # Docusaurus cache and generated files 130 | 131 | .docusaurus 132 | 133 | # Serverless directories 134 | 135 | .serverless/ 136 | 137 | # FuseBox cache 138 | 139 | .fusebox/ 140 | 141 | # DynamoDB Local files 142 | 143 | .dynamodb/ 144 | 145 | # TernJS port file 146 | 147 | .tern-port 148 | 149 | # Stores VSCode versions used for testing VSCode extensions 150 | 151 | .vscode-test 152 | 153 | # yarn v2 154 | 155 | .yarn/cache 156 | .yarn/unplugged 157 | .yarn/build-state.yml 158 | .yarn/install-state.gz 159 | .pnp.\* 160 | 161 | # wrangler project 162 | 163 | .dev.vars* 164 | !.dev.vars.example 165 | .env* 166 | !.env.example 167 | .wrangler/ 168 | 169 | # claude 170 | .claude/ 171 | 172 | # playwright mcp 173 | .playwright-mcp/ 174 | 175 | -------------------------------------------------------------------------------- /src/cloudflare/queries.ts: -------------------------------------------------------------------------------- 1 | import z from "zod"; 2 | 3 | /** 4 | * Zod schema for all supported metric query names. 5 | * Includes both account-level and zone-level queries. 6 | */ 7 | export const MetricQueryNameSchema = z.enum([ 8 | // Account-level 9 | "worker-totals", 10 | "logpush-account", 11 | "magic-transit", 12 | // Zone-level 13 | "http-metrics", 14 | "adaptive-metrics", 15 | "edge-country-metrics", 16 | "colo-metrics", 17 | "colo-error-metrics", 18 | "request-method-metrics", 19 | "health-check-metrics", 20 | "load-balancer-metrics", 21 | "logpush-zone", 22 | "origin-status-metrics", 23 | "cache-miss-metrics", 24 | // REST API 25 | "ssl-certificates", 26 | "lb-weight-metrics", 27 | ]); 28 | 29 | /** 30 | * Union of all metric query names (account and zone level). 31 | */ 32 | export type MetricQueryName = z.infer; 33 | 34 | /** 35 | * Account-scoped metric queries (require single accountTag). 36 | */ 37 | export const ACCOUNT_LEVEL_QUERIES = [ 38 | "worker-totals", 39 | "logpush-account", 40 | "magic-transit", 41 | ] as const; 42 | 43 | /** 44 | * Union of account-level query names. 45 | */ 46 | export type AccountLevelQuery = (typeof ACCOUNT_LEVEL_QUERIES)[number]; 47 | 48 | /** 49 | * Zone-scoped metric queries (support multiple zoneIDs). 50 | */ 51 | export const ZONE_LEVEL_QUERIES = [ 52 | "http-metrics", 53 | "adaptive-metrics", 54 | "edge-country-metrics", 55 | "colo-metrics", 56 | "colo-error-metrics", 57 | "request-method-metrics", 58 | "health-check-metrics", 59 | "load-balancer-metrics", 60 | "logpush-zone", 61 | "origin-status-metrics", 62 | "cache-miss-metrics", 63 | "ssl-certificates", 64 | "lb-weight-metrics", 65 | ] as const; 66 | 67 | /** 68 | * Union of zone-level query names. 69 | */ 70 | export type ZoneLevelQuery = (typeof ZONE_LEVEL_QUERIES)[number]; 71 | 72 | /** 73 | * Type guard for account-level queries. 74 | * 75 | * @param query Query name to check. 76 | * @returns True if query is account-level. 77 | */ 78 | export function isAccountLevelQuery(query: string): query is AccountLevelQuery { 79 | return (ACCOUNT_LEVEL_QUERIES as readonly string[]).includes(query); 80 | } 81 | 82 | /** 83 | * Type guard for zone-level queries. 84 | * 85 | * @param query Query name to check. 86 | * @returns True if query is zone-level. 87 | */ 88 | export function isZoneLevelQuery(query: string): query is ZoneLevelQuery { 89 | return (ZONE_LEVEL_QUERIES as readonly string[]).includes(query); 90 | } 91 | 92 | /** 93 | * Query types available on free tier accounts. 94 | */ 95 | export const FREE_TIER_QUERIES = [ 96 | "worker-totals", 97 | "logpush-account", 98 | "magic-transit", 99 | ] as const; 100 | 101 | /** 102 | * Type for free tier query names. 103 | */ 104 | export type FreeTierQuery = (typeof FREE_TIER_QUERIES)[number]; 105 | 106 | /** 107 | * Zone-level GraphQL queries that require paid tier. 108 | * Free tier zones don't have access to adaptive analytics endpoints. 109 | */ 110 | export const PAID_TIER_GRAPHQL_QUERIES = [ 111 | "http-metrics", 112 | "adaptive-metrics", 113 | "edge-country-metrics", 114 | "colo-metrics", 115 | "colo-error-metrics", 116 | "request-method-metrics", 117 | "health-check-metrics", 118 | "load-balancer-metrics", 119 | "logpush-zone", 120 | "origin-status-metrics", 121 | "cache-miss-metrics", 122 | ] as const; 123 | 124 | /** 125 | * Type for paid tier GraphQL query names. 126 | */ 127 | export type PaidTierGraphQLQuery = (typeof PAID_TIER_GRAPHQL_QUERIES)[number]; 128 | 129 | /** 130 | * Type guard for paid tier GraphQL queries. 131 | * 132 | * @param query Query name to check. 133 | * @returns True if query requires paid tier. 134 | */ 135 | export function isPaidTierGraphQLQuery( 136 | query: string, 137 | ): query is PaidTierGraphQLQuery { 138 | return (PAID_TIER_GRAPHQL_QUERIES as readonly string[]).includes(query); 139 | } 140 | -------------------------------------------------------------------------------- /src/lib/config.ts: -------------------------------------------------------------------------------- 1 | import z from "zod"; 2 | 3 | /** 4 | * Basic auth configuration state. 5 | */ 6 | export type BasicAuthConfig = 7 | | { readonly enabled: false } 8 | | { 9 | readonly enabled: true; 10 | readonly username: string; 11 | readonly password: string; 12 | }; 13 | 14 | /** 15 | * Application configuration parsed from environment variables. 16 | */ 17 | export type AppConfig = { 18 | readonly excludeHost: boolean; 19 | readonly httpStatusGroup: boolean; 20 | readonly metricsDenylist: ReadonlySet; 21 | readonly cfAccounts: ReadonlySet | null; 22 | readonly cfZones: ReadonlySet | null; 23 | readonly cfFreeTierAccounts: ReadonlySet; 24 | readonly metricsPath: string; 25 | readonly disableUi: boolean; 26 | readonly disableConfigApi: boolean; 27 | readonly basicAuth: BasicAuthConfig; 28 | }; 29 | 30 | /** 31 | * Parses comma-separated string into Set, trimming whitespace. 32 | * Returns empty Set for empty/undefined input. 33 | * 34 | * @param value Comma-separated string or undefined. 35 | * @returns Set of trimmed non-empty strings. 36 | */ 37 | function parseCommaSeparated(value: string | undefined): Set { 38 | if (!value || value.trim() === "") { 39 | return new Set(); 40 | } 41 | return new Set( 42 | value 43 | .split(",") 44 | .map((s) => s.trim()) 45 | .filter((s) => s.length > 0), 46 | ); 47 | } 48 | 49 | /** 50 | * Optional environment variables not defined in wrangler.jsonc vars. 51 | */ 52 | type OptionalEnvVars = { 53 | METRICS_DENYLIST?: string; 54 | CF_ACCOUNTS?: string; 55 | CF_ZONES?: string; 56 | CF_FREE_TIER_ACCOUNTS?: string; 57 | BASIC_AUTH_USER?: string; 58 | BASIC_AUTH_PASSWORD?: string; 59 | }; 60 | 61 | /** 62 | * Parses application configuration from environment variables. 63 | * Uses Zod for type coercion with sensible defaults. 64 | * 65 | * @param env Worker environment bindings. 66 | * @returns Parsed application configuration. 67 | */ 68 | export function parseConfig(env: Env): AppConfig { 69 | const optionalEnv = env as Env & OptionalEnvVars; 70 | 71 | const excludeHost = z.coerce.boolean().catch(false).parse(env.EXCLUDE_HOST); 72 | const httpStatusGroup = z.coerce 73 | .boolean() 74 | .catch(false) 75 | .parse(env.CF_HTTP_STATUS_GROUP); 76 | const metricsPath = z 77 | .string() 78 | .min(1) 79 | .catch("/metrics") 80 | .parse(env.METRICS_PATH); 81 | const disableUi = z.coerce.boolean().catch(false).parse(env.DISABLE_UI); 82 | const disableConfigApi = z.coerce 83 | .boolean() 84 | .catch(false) 85 | .parse(env.DISABLE_CONFIG_API); 86 | 87 | const metricsDenylist = parseCommaSeparated(optionalEnv.METRICS_DENYLIST); 88 | const cfAccountsRaw = parseCommaSeparated(optionalEnv.CF_ACCOUNTS); 89 | const cfAccounts = cfAccountsRaw.size > 0 ? cfAccountsRaw : null; 90 | const cfZonesRaw = parseCommaSeparated(optionalEnv.CF_ZONES); 91 | const cfZones = cfZonesRaw.size > 0 ? cfZonesRaw : null; 92 | const cfFreeTierAccounts = parseCommaSeparated( 93 | optionalEnv.CF_FREE_TIER_ACCOUNTS, 94 | ); 95 | 96 | const basicAuth = parseBasicAuthConfig( 97 | optionalEnv.BASIC_AUTH_USER, 98 | optionalEnv.BASIC_AUTH_PASSWORD, 99 | ); 100 | 101 | return { 102 | excludeHost, 103 | httpStatusGroup, 104 | metricsDenylist, 105 | cfAccounts, 106 | cfZones, 107 | cfFreeTierAccounts, 108 | metricsPath, 109 | disableUi, 110 | disableConfigApi, 111 | basicAuth, 112 | }; 113 | } 114 | 115 | /** 116 | * Parses basic auth configuration from environment variables. 117 | * Logs warnings if configuration is incomplete. 118 | * 119 | * @param user BASIC_AUTH_USER environment variable. 120 | * @param password BASIC_AUTH_PASSWORD environment variable. 121 | * @returns Basic auth configuration. 122 | */ 123 | function parseBasicAuthConfig( 124 | user: string | undefined, 125 | password: string | undefined, 126 | ): BasicAuthConfig { 127 | const hasUser = user !== undefined && user.trim() !== ""; 128 | const hasPassword = password !== undefined && password.trim() !== ""; 129 | 130 | if (hasUser && hasPassword) { 131 | return { enabled: true, username: user.trim(), password: password.trim() }; 132 | } 133 | 134 | if (hasUser && !hasPassword) { 135 | console.warn( 136 | "[config] BASIC_AUTH_USER is set but BASIC_AUTH_PASSWORD is missing - basic auth disabled", 137 | ); 138 | } 139 | 140 | if (!hasUser && hasPassword) { 141 | console.warn( 142 | "[config] BASIC_AUTH_PASSWORD is set but BASIC_AUTH_USER is missing - basic auth disabled", 143 | ); 144 | } 145 | 146 | return { enabled: false }; 147 | } 148 | -------------------------------------------------------------------------------- /src/lib/health.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CLOUDFLARE_GQL_URL, 3 | getCloudflareMetricsClient, 4 | } from "../cloudflare/client"; 5 | import { extractErrorInfo, withTimeout } from "./errors"; 6 | import { getConfig } from "./runtime-config"; 7 | 8 | const CHECK_TIMEOUT_MS = 5_000; 9 | 10 | type CheckStatus = "healthy" | "unhealthy"; 11 | 12 | type HealthCheck = { 13 | status: CheckStatus; 14 | latency_ms: number; 15 | error?: string; 16 | error_code?: string; 17 | }; 18 | 19 | type HealthResponse = { 20 | status: CheckStatus; 21 | timestamp: string; 22 | checks: { 23 | cloudflare_api: HealthCheck; 24 | graphql_api: HealthCheck; 25 | }; 26 | }; 27 | 28 | type CachedHealth = { 29 | response: HealthResponse; 30 | expires: number; 31 | }; 32 | 33 | let healthCache: CachedHealth | null = null; 34 | 35 | /** 36 | * Check Cloudflare REST API connectivity by fetching accounts. 37 | * 38 | * @param env Environment variables. 39 | * @returns Health check result. 40 | */ 41 | async function checkCloudflareApi(env: Env): Promise { 42 | const start = performance.now(); 43 | 44 | try { 45 | const client = getCloudflareMetricsClient(env); 46 | const result = await withTimeout( 47 | client.getAccounts(), 48 | CHECK_TIMEOUT_MS, 49 | "Cloudflare API health check", 50 | ); 51 | const latency_ms = Math.round(performance.now() - start); 52 | 53 | if (result.ok) { 54 | return { status: "healthy", latency_ms }; 55 | } 56 | return { 57 | status: "unhealthy", 58 | latency_ms, 59 | error: result.error.message, 60 | error_code: result.error.code, 61 | }; 62 | } catch (err) { 63 | const latency_ms = Math.round(performance.now() - start); 64 | const info = extractErrorInfo(err); 65 | return { 66 | status: "unhealthy", 67 | latency_ms, 68 | error: info.message, 69 | error_code: info.code, 70 | }; 71 | } 72 | } 73 | 74 | /** 75 | * Check Cloudflare GraphQL API connectivity via introspection. 76 | * 77 | * @param env Environment variables. 78 | * @returns Health check result. 79 | */ 80 | async function checkGraphqlApi(env: Env): Promise { 81 | const start = performance.now(); 82 | 83 | try { 84 | const result = await withTimeout( 85 | fetch(CLOUDFLARE_GQL_URL, { 86 | method: "POST", 87 | headers: { 88 | "Content-Type": "application/json", 89 | Authorization: `Bearer ${env.CLOUDFLARE_API_TOKEN}`, 90 | }, 91 | body: JSON.stringify({ 92 | query: "{ __typename }", 93 | }), 94 | }), 95 | CHECK_TIMEOUT_MS, 96 | "GraphQL API health check", 97 | ); 98 | 99 | const latency_ms = Math.round(performance.now() - start); 100 | 101 | if (!result.ok) { 102 | return { 103 | status: "unhealthy", 104 | latency_ms, 105 | error: result.error.message, 106 | error_code: result.error.code, 107 | }; 108 | } 109 | 110 | const response = result.value; 111 | if (!response.ok) { 112 | return { 113 | status: "unhealthy", 114 | latency_ms, 115 | error: `HTTP ${response.status}`, 116 | error_code: "API_UNAVAILABLE", 117 | }; 118 | } 119 | 120 | return { status: "healthy", latency_ms }; 121 | } catch (err) { 122 | const latency_ms = Math.round(performance.now() - start); 123 | const info = extractErrorInfo(err); 124 | return { 125 | status: "unhealthy", 126 | latency_ms, 127 | error: info.message, 128 | error_code: info.code, 129 | }; 130 | } 131 | } 132 | 133 | /** 134 | * Perform health check with configurable caching. 135 | * 136 | * @param env Environment variables. 137 | * @returns Health check response. 138 | */ 139 | export async function checkHealth(env: Env): Promise { 140 | const now = Date.now(); 141 | const config = await getConfig(env); 142 | const cacheTtlMs = config.healthCheckCacheTtlSeconds * 1000; 143 | 144 | if (healthCache && healthCache.expires > now) { 145 | return healthCache.response; 146 | } 147 | 148 | const [cloudflareApi, graphqlApi] = await Promise.all([ 149 | checkCloudflareApi(env), 150 | checkGraphqlApi(env), 151 | ]); 152 | 153 | const allHealthy = 154 | cloudflareApi.status === "healthy" && graphqlApi.status === "healthy"; 155 | 156 | const response: HealthResponse = { 157 | status: allHealthy ? "healthy" : "unhealthy", 158 | timestamp: new Date().toISOString(), 159 | checks: { 160 | cloudflare_api: cloudflareApi, 161 | graphql_api: graphqlApi, 162 | }, 163 | }; 164 | 165 | healthCache = { 166 | response, 167 | expires: now + cacheTtlMs, 168 | }; 169 | 170 | return response; 171 | } 172 | 173 | /** 174 | * Build HTTP response from health check result. 175 | * 176 | * @param health Health check response. 177 | * @returns HTTP response with JSON body. 178 | */ 179 | export function healthResponse(health: HealthResponse): Response { 180 | const status = health.status === "healthy" ? 200 : 503; 181 | return new Response(JSON.stringify(health), { 182 | status, 183 | headers: { "Content-Type": "application/json" }, 184 | }); 185 | } 186 | -------------------------------------------------------------------------------- /src/worker.tsx: -------------------------------------------------------------------------------- 1 | import { env } from "cloudflare:workers"; 2 | import { Hono } from "hono"; 3 | import { LandingPage } from "./components/LandingPage"; 4 | import { AccountMetricCoordinator } from "./durable-objects/AccountMetricCoordinator"; 5 | import { MetricCoordinator } from "./durable-objects/MetricCoordinator"; 6 | import { MetricExporter } from "./durable-objects/MetricExporter"; 7 | import { type AppConfig, parseConfig } from "./lib/config"; 8 | import { checkHealth, healthResponse } from "./lib/health"; 9 | import { configFromEnv, createLogger } from "./lib/logger"; 10 | import { 11 | ConfigKeySchema, 12 | getConfig, 13 | getConfigKey, 14 | getEnvDefaults, 15 | resetAllConfig, 16 | resetConfigKey, 17 | setConfigKey, 18 | } from "./lib/runtime-config"; 19 | 20 | export { MetricCoordinator, AccountMetricCoordinator, MetricExporter }; 21 | 22 | type Variables = { config: AppConfig }; 23 | 24 | const app = new Hono<{ Bindings: Env; Variables: Variables }>(); 25 | 26 | // Parse config middleware 27 | app.use("*", async (c, next) => { 28 | c.set("config", parseConfig(c.env)); 29 | await next(); 30 | }); 31 | 32 | // Basic auth middleware 33 | app.use("*", async (c, next) => { 34 | const { basicAuth } = c.var.config; 35 | 36 | if (!basicAuth.enabled) { 37 | return next(); 38 | } 39 | 40 | const authHeader = c.req.header("Authorization"); 41 | 42 | if (!authHeader || !authHeader.startsWith("Basic ")) { 43 | return c.text("Unauthorized", 401, { 44 | "WWW-Authenticate": 'Basic realm="Cloudflare Exporter"', 45 | }); 46 | } 47 | 48 | const base64Credentials = authHeader.slice(6); 49 | let credentials: string; 50 | try { 51 | credentials = atob(base64Credentials); 52 | } catch { 53 | return c.text("Unauthorized", 401, { 54 | "WWW-Authenticate": 'Basic realm="Cloudflare Exporter"', 55 | }); 56 | } 57 | 58 | const [username, password] = credentials.split(":"); 59 | 60 | if (username !== basicAuth.username || password !== basicAuth.password) { 61 | return c.text("Unauthorized", 401, { 62 | "WWW-Authenticate": 'Basic realm="Cloudflare Exporter"', 63 | }); 64 | } 65 | 66 | return next(); 67 | }); 68 | 69 | // Disable guards 70 | app.use("*", async (c, next) => { 71 | const path = c.req.path; 72 | if (c.var.config.disableUi && path === "/") { 73 | return c.text("Not Found", 404); 74 | } 75 | if (c.var.config.disableConfigApi && path.startsWith("/config")) { 76 | return c.text("Not Found", 404); 77 | } 78 | await next(); 79 | }); 80 | 81 | // Dynamic metrics path middleware (runs before routing) 82 | app.get(env.METRICS_PATH, async (c) => { 83 | const logger = createLogger("worker", configFromEnv(c.env)).withContext({ 84 | request_id: crypto.randomUUID(), 85 | }); 86 | logger.info("Metrics request received"); 87 | 88 | try { 89 | const coordinator = await MetricCoordinator.get(c.env); 90 | const output = await coordinator.export(); 91 | logger.info("Metrics exported successfully"); 92 | return c.text(output, 200, { 93 | "Content-Type": "text/plain; charset=utf-8", 94 | }); 95 | } catch (error) { 96 | const message = error instanceof Error ? error.message : String(error); 97 | logger.error("Failed to collect metrics", { error: message }); 98 | return c.text(`Error collecting metrics: ${message}`, 500); 99 | } 100 | }); 101 | 102 | // Routes 103 | app.get("/", (c) => c.html()); 104 | 105 | app.get("/health", async (c) => { 106 | const health = await checkHealth(c.env); 107 | return healthResponse(health); 108 | }); 109 | 110 | // Config API routes 111 | app.get("/config", async (c) => { 112 | const config = await getConfig(c.env); 113 | return c.json(config); 114 | }); 115 | 116 | app.get("/config/defaults", (c) => { 117 | const defaults = getEnvDefaults(c.env); 118 | return c.json(defaults); 119 | }); 120 | 121 | app.get("/config/:key", async (c) => { 122 | const keyResult = ConfigKeySchema.safeParse(c.req.param("key")); 123 | if (!keyResult.success) { 124 | return c.json({ error: "Invalid config key" }, 400); 125 | } 126 | const value = await getConfigKey(c.env, keyResult.data); 127 | return c.json({ key: keyResult.data, value }); 128 | }); 129 | 130 | app.put("/config/:key", async (c) => { 131 | const keyResult = ConfigKeySchema.safeParse(c.req.param("key")); 132 | if (!keyResult.success) { 133 | return c.json({ error: "Invalid config key" }, 400); 134 | } 135 | const body = await c.req.json<{ value: unknown }>().catch(() => null); 136 | if (!body || !("value" in body)) { 137 | return c.json({ error: "Request body must contain 'value'" }, 400); 138 | } 139 | const result = await setConfigKey(c.env, keyResult.data, body.value); 140 | if (!result.success) { 141 | return c.json( 142 | { error: "Invalid value", details: result.error.issues }, 143 | 400, 144 | ); 145 | } 146 | return c.json(result.config); 147 | }); 148 | 149 | app.delete("/config/:key", async (c) => { 150 | const keyResult = ConfigKeySchema.safeParse(c.req.param("key")); 151 | if (!keyResult.success) { 152 | return c.json({ error: "Invalid config key" }, 400); 153 | } 154 | const config = await resetConfigKey(c.env, keyResult.data); 155 | return c.json(config); 156 | }); 157 | 158 | app.delete("/config", async (c) => { 159 | const config = await resetAllConfig(c.env); 160 | return c.json(config); 161 | }); 162 | 163 | app.notFound((c) => c.text("Not Found", 404)); 164 | 165 | export default app; 166 | -------------------------------------------------------------------------------- /src/lib/types.ts: -------------------------------------------------------------------------------- 1 | import z from "zod"; 2 | import { MetricDefinitionSchema } from "./metrics"; 3 | 4 | // Re-export metric types from metrics.ts 5 | export type { MetricDefinition, MetricType, MetricValue } from "./metrics"; 6 | export { MetricDefinitionSchema } from "./metrics"; 7 | 8 | /** 9 | * Zod schema for MetricExporter scope: account-level or zone-level. 10 | */ 11 | export const ScopeTypeSchema = z.enum(["account", "zone"]); 12 | 13 | /** 14 | * Scope discriminator for MetricExporter DOs. 15 | */ 16 | export type ScopeType = z.infer; 17 | 18 | /** 19 | * String literal type for MetricExporter DO IDs: "scope:id:queryName". 20 | */ 21 | export type MetricExporterIdString = 22 | `${"account" | "zone"}:${string}:${string}`; 23 | 24 | /** 25 | * Zod schema that parses and validates MetricExporter DO ID strings. 26 | * Transforms "scope:id:query" into structured object. 27 | */ 28 | export const MetricExporterIdSchema = z 29 | .string() 30 | .regex(/^(account|zone):[^:]+:[^:]+$/) 31 | .transform((s) => { 32 | const parts = s.split(":"); 33 | // Regex guarantees exactly 3 parts with account|zone prefix 34 | const scopeType = ScopeTypeSchema.parse(parts[0]); 35 | const scopeId = z.string().min(1).parse(parts[1]); 36 | const queryName = z.string().min(1).parse(parts[2]); 37 | return { scopeType, scopeId, queryName }; 38 | }); 39 | 40 | /** 41 | * Parsed MetricExporter DO identifier with scope, ID, and query name. 42 | */ 43 | export type MetricExporterId = z.infer; 44 | 45 | /** 46 | * Zod schema for counter state tracking accumulated total. 47 | * Cloudflare API returns window-based totals, so we just sum them. 48 | */ 49 | export const CounterStateSchema = z 50 | .object({ 51 | accumulated: z.number(), 52 | }) 53 | .readonly(); 54 | 55 | /** 56 | * Counter state for Prometheus monotonic counter semantics. 57 | */ 58 | export type CounterState = z.infer; 59 | 60 | /** 61 | * Zod schema for persistent metric state in MetricExporter DO storage. 62 | */ 63 | export const MetricStateSchema = z 64 | .object({ 65 | accountId: z.string().optional(), 66 | accountName: z.string().optional(), 67 | counters: z.record(z.string(), CounterStateSchema), 68 | metrics: z.array(MetricDefinitionSchema).readonly(), 69 | lastFetch: z.number(), 70 | lastError: z.string().optional(), 71 | }) 72 | .readonly(); 73 | 74 | /** 75 | * Persistent metric state stored in MetricExporter DO. 76 | */ 77 | export type MetricState = z.infer; 78 | 79 | /** 80 | * Zod schema for Cloudflare account API response. 81 | */ 82 | export const AccountSchema = z 83 | .object({ 84 | id: z.string(), 85 | name: z.string(), 86 | }) 87 | .readonly(); 88 | 89 | /** 90 | * Cloudflare account with ID and name. 91 | */ 92 | export type Account = z.infer; 93 | 94 | /** 95 | * Zod schema for Cloudflare zone API response with plan and account. 96 | */ 97 | export const ZoneSchema = z 98 | .object({ 99 | id: z.string(), 100 | name: z.string(), 101 | status: z.string(), 102 | plan: z.object({ 103 | id: z.string(), 104 | name: z.string(), 105 | }), 106 | account: z.object({ 107 | id: z.string(), 108 | name: z.string(), 109 | }), 110 | }) 111 | .readonly(); 112 | 113 | /** 114 | * Cloudflare zone with plan and account associations. 115 | */ 116 | export type Zone = z.infer; 117 | 118 | /** 119 | * Zod schema for Cloudflare SSL certificate API response. 120 | */ 121 | export const SSLCertificateSchema = z 122 | .object({ 123 | id: z.string(), 124 | type: z.string(), 125 | status: z.string(), 126 | issuer: z.string(), 127 | expiresOn: z.string(), 128 | hosts: z.array(z.string()), 129 | }) 130 | .readonly(); 131 | 132 | /** 133 | * SSL certificate with expiration and host coverage. 134 | */ 135 | export type SSLCertificate = z.infer; 136 | 137 | /** 138 | * Zod schema for GraphQL query time range with ISO 8601 timestamps. 139 | */ 140 | export const TimeRangeSchema = z 141 | .object({ 142 | mintime: z.string(), 143 | maxtime: z.string(), 144 | }) 145 | .readonly(); 146 | 147 | /** 148 | * Time range for GraphQL queries with start and end timestamps. 149 | */ 150 | export type TimeRange = z.infer; 151 | 152 | /** 153 | * Zod schema for load balancer origin configuration. 154 | */ 155 | export const LoadBalancerOriginSchema = z 156 | .object({ 157 | name: z.string(), 158 | address: z.string(), 159 | enabled: z.boolean(), 160 | weight: z.number(), 161 | }) 162 | .passthrough() 163 | .readonly(); 164 | 165 | /** 166 | * Load balancer origin with weight configuration. 167 | */ 168 | export type LoadBalancerOrigin = z.infer; 169 | 170 | /** 171 | * Zod schema for load balancer pool configuration. 172 | */ 173 | export const LoadBalancerPoolSchema = z 174 | .object({ 175 | id: z.string(), 176 | name: z.string(), 177 | enabled: z.boolean(), 178 | origins: z.array(LoadBalancerOriginSchema), 179 | }) 180 | .passthrough() 181 | .readonly(); 182 | 183 | /** 184 | * Load balancer pool with origins. 185 | */ 186 | export type LoadBalancerPool = z.infer; 187 | 188 | /** 189 | * Combined load balancer with resolved pools. 190 | */ 191 | export type LoadBalancerWithPools = { 192 | readonly id: string; 193 | readonly name: string; 194 | readonly pools: readonly LoadBalancerPool[]; 195 | }; 196 | -------------------------------------------------------------------------------- /src/lib/prometheus.ts: -------------------------------------------------------------------------------- 1 | import type { MetricDefinition } from "./metrics"; 2 | 3 | /** 4 | * Options for Prometheus serialization. 5 | */ 6 | export type SerializeOptions = { 7 | /** Set of metric names to exclude from output. */ 8 | denylist?: ReadonlySet; 9 | /** Set of label keys to exclude from all metrics. */ 10 | excludeLabels?: ReadonlySet; 11 | }; 12 | 13 | /** 14 | * Serializes MetricDefinition array to Prometheus text exposition format. 15 | * Groups metrics by name, outputs HELP/TYPE headers, then values. 16 | * Aggregates duplicate label combinations (sum for counters, max for gauges). 17 | * 18 | * @param metrics Array of metric definitions to serialize. 19 | * @param options Serialization options for filtering. 20 | * @returns Prometheus-formatted metrics string. 21 | */ 22 | export function serializeToPrometheus( 23 | metrics: readonly MetricDefinition[], 24 | options?: SerializeOptions, 25 | ): string { 26 | const denylist = options?.denylist ?? new Set(); 27 | const excludeLabels = options?.excludeLabels ?? new Set(); 28 | 29 | // Group metrics by name to consolidate HELP/TYPE headers 30 | const grouped = new Map(); 31 | 32 | for (const metric of metrics) { 33 | // Skip denied metrics 34 | if (denylist.has(metric.name)) { 35 | continue; 36 | } 37 | 38 | // Filter excluded labels from all values 39 | const filteredValues = 40 | excludeLabels.size > 0 41 | ? metric.values.map((v) => ({ 42 | ...v, 43 | labels: filterLabels(v.labels, excludeLabels), 44 | })) 45 | : metric.values; 46 | 47 | const existing = grouped.get(metric.name); 48 | if (existing) { 49 | // Merge values 50 | grouped.set(metric.name, { 51 | ...existing, 52 | values: [...existing.values, ...filteredValues], 53 | }); 54 | } else { 55 | grouped.set(metric.name, { ...metric, values: [...filteredValues] }); 56 | } 57 | } 58 | 59 | const lines: string[] = []; 60 | 61 | for (const [name, metric] of grouped) { 62 | // HELP line 63 | lines.push(`# HELP ${name} ${escapeHelp(metric.help)}`); 64 | // TYPE line 65 | lines.push(`# TYPE ${name} ${metric.type}`); 66 | 67 | // Aggregate values by label signature to eliminate duplicates 68 | const aggregated = aggregateByLabels(metric.values, metric.type); 69 | 70 | // Value lines 71 | for (const { labels, value } of aggregated) { 72 | const labelStr = formatLabels(labels); 73 | lines.push(`${name}${labelStr} ${formatValue(value)}`); 74 | } 75 | 76 | // Blank line between metrics for readability 77 | lines.push(""); 78 | } 79 | 80 | return lines.join("\n"); 81 | } 82 | 83 | /** 84 | * Aggregates metric values with identical labels. 85 | * Counters are summed; gauges take the maximum value. 86 | * 87 | * @param values Array of metric values to aggregate. 88 | * @param type Metric type (counter, gauge, etc.). 89 | * @returns Deduplicated array of metric values. 90 | */ 91 | function aggregateByLabels( 92 | values: readonly { labels: Record; value: number }[], 93 | type: string, 94 | ): { labels: Record; value: number }[] { 95 | const bySignature = new Map< 96 | string, 97 | { labels: Record; value: number } 98 | >(); 99 | 100 | for (const { labels, value } of values) { 101 | const sig = labelSignature(labels); 102 | const existing = bySignature.get(sig); 103 | 104 | if (existing) { 105 | if (type === "counter") { 106 | existing.value += value; 107 | } else { 108 | // For gauges (including percentiles), take max as upper bound 109 | existing.value = Math.max(existing.value, value); 110 | } 111 | } else { 112 | bySignature.set(sig, { labels, value }); 113 | } 114 | } 115 | 116 | return [...bySignature.values()]; 117 | } 118 | 119 | /** 120 | * Creates stable signature from labels for deduplication. 121 | * 122 | * @param labels Label key-value pairs. 123 | * @returns Stable string signature for comparison. 124 | */ 125 | function labelSignature(labels: Record): string { 126 | return Object.entries(labels) 127 | .sort(([a], [b]) => a.localeCompare(b)) 128 | .map(([k, v]) => `${k}\x00${v}`) 129 | .join("\x01"); 130 | } 131 | 132 | /** 133 | * Filters out excluded label keys from a labels object. 134 | * 135 | * @param labels Original label key-value pairs. 136 | * @param exclude Set of label keys to exclude. 137 | * @returns Filtered labels object. 138 | */ 139 | function filterLabels( 140 | labels: Record, 141 | exclude: ReadonlySet, 142 | ): Record { 143 | const filtered: Record = {}; 144 | for (const [key, value] of Object.entries(labels)) { 145 | if (!exclude.has(key)) { 146 | filtered[key] = value; 147 | } 148 | } 149 | return filtered; 150 | } 151 | 152 | /** 153 | * Formats labels object into Prometheus label string. 154 | * 155 | * @param labels Label key-value pairs. 156 | * @returns Formatted label string like `{key="value"}` or empty string. 157 | */ 158 | function formatLabels(labels: Record): string { 159 | const entries = Object.entries(labels); 160 | if (entries.length === 0) return ""; 161 | 162 | const formatted = entries 163 | .map(([key, value]) => `${key}="${escapeLabel(value)}"`) 164 | .join(","); 165 | 166 | return `{${formatted}}`; 167 | } 168 | 169 | /** 170 | * Formats numeric value for Prometheus output. 171 | * 172 | * @param value Numeric value to format. 173 | * @returns String representation handling NaN and Infinity. 174 | */ 175 | function formatValue(value: number): string { 176 | if (Number.isNaN(value)) return "NaN"; 177 | if (!Number.isFinite(value)) return value > 0 ? "+Inf" : "-Inf"; 178 | return String(value); 179 | } 180 | 181 | /** 182 | * Escapes special characters in HELP text. 183 | * 184 | * @param help Raw help text. 185 | * @returns Escaped help text. 186 | */ 187 | function escapeHelp(help: string): string { 188 | return help.replace(/\\/g, "\\\\").replace(/\n/g, "\\n"); 189 | } 190 | 191 | /** 192 | * Escapes special characters in label values. 193 | * 194 | * @param value Raw label value. 195 | * @returns Escaped label value. 196 | */ 197 | function escapeLabel(value: string): string { 198 | return value 199 | .replace(/\\/g, "\\\\") 200 | .replace(/"/g, '\\"') 201 | .replace(/\n/g, "\\n"); 202 | } 203 | -------------------------------------------------------------------------------- /src/lib/logger.ts: -------------------------------------------------------------------------------- 1 | import { createConsola, type LogObject } from "consola"; 2 | 3 | // Raw ANSI escape codes - bypass consola's color detection which doesn't work in wrangler dev 4 | const ansi = { 5 | reset: "\x1b[0m", 6 | dim: "\x1b[2m", 7 | bold: "\x1b[1m", 8 | red: "\x1b[31m", 9 | green: "\x1b[32m", 10 | yellow: "\x1b[33m", 11 | blue: "\x1b[34m", 12 | magenta: "\x1b[35m", 13 | cyan: "\x1b[36m", 14 | white: "\x1b[37m", 15 | gray: "\x1b[90m", 16 | }; 17 | 18 | const c = { 19 | dim: (s: string) => `${ansi.dim}${s}${ansi.reset}`, 20 | red: (s: string) => `${ansi.red}${s}${ansi.reset}`, 21 | green: (s: string) => `${ansi.green}${s}${ansi.reset}`, 22 | yellow: (s: string) => `${ansi.yellow}${s}${ansi.reset}`, 23 | cyan: (s: string) => `${ansi.cyan}${s}${ansi.reset}`, 24 | white: (s: string) => `${ansi.white}${s}${ansi.reset}`, 25 | gray: (s: string) => `${ansi.gray}${s}${ansi.reset}`, 26 | magenta: (s: string) => `${ansi.magenta}${s}${ansi.reset}`, 27 | }; 28 | 29 | /** 30 | * Log severity levels. 31 | */ 32 | export type LogLevel = "debug" | "info" | "warn" | "error"; 33 | 34 | /** 35 | * Output format: json for structured logs, pretty for human-readable. 36 | */ 37 | export type LogFormat = "json" | "pretty"; 38 | 39 | /** 40 | * Key-value pairs attached to log entries. 41 | */ 42 | export type StructuredData = Record; 43 | 44 | /** 45 | * Structured logger with level methods, namespacing, and context. 46 | */ 47 | export interface Logger { 48 | /** 49 | * Log debug message. 50 | * 51 | * @param msg Message text. 52 | * @param data Optional structured data. 53 | */ 54 | debug(msg: string, data?: StructuredData): void; 55 | 56 | /** 57 | * Log info message. 58 | * 59 | * @param msg Message text. 60 | * @param data Optional structured data. 61 | */ 62 | info(msg: string, data?: StructuredData): void; 63 | 64 | /** 65 | * Log warning message. 66 | * 67 | * @param msg Message text. 68 | * @param data Optional structured data. 69 | */ 70 | warn(msg: string, data?: StructuredData): void; 71 | 72 | /** 73 | * Log error message. 74 | * 75 | * @param msg Message text. 76 | * @param data Optional structured data. 77 | */ 78 | error(msg: string, data?: StructuredData): void; 79 | 80 | /** 81 | * Create child logger with namespaced tag. 82 | * 83 | * @param namespace Namespace appended to parent tag with colon separator. 84 | * @returns New logger instance. 85 | */ 86 | child(namespace: string): Logger; 87 | 88 | /** 89 | * Create logger with merged context data. 90 | * 91 | * @param ctx Context data merged into all log entries. 92 | * @returns New logger instance. 93 | */ 94 | withContext(ctx: StructuredData): Logger; 95 | } 96 | 97 | /** 98 | * Logger configuration. 99 | */ 100 | export interface LoggerConfig { 101 | /** Output format, defaults to pretty. */ 102 | format?: LogFormat; 103 | 104 | /** Minimum log level, defaults to info. */ 105 | level?: LogLevel; 106 | } 107 | 108 | const LEVELS: Record = { 109 | debug: 0, 110 | info: 1, 111 | warn: 2, 112 | error: 3, 113 | }; 114 | 115 | const LEVEL_COLORS: Record string> = { 116 | debug: c.gray, 117 | info: c.cyan, 118 | warn: c.yellow, 119 | error: c.red, 120 | }; 121 | 122 | const LEVEL_ICONS: Record = { 123 | debug: "●", 124 | info: "◆", 125 | warn: "▲", 126 | error: "✖", 127 | }; 128 | 129 | /** 130 | * Format current time as HH:MM:SS. 131 | * 132 | * @returns Formatted time string. 133 | */ 134 | function formatTime(): string { 135 | const now = new Date(); 136 | return `${String(now.getHours()).padStart(2, "0")}:${String(now.getMinutes()).padStart(2, "0")}:${String(now.getSeconds()).padStart(2, "0")}`; 137 | } 138 | 139 | /** 140 | * Get current UTC timestamp in ISO format. 141 | * 142 | * @returns ISO 8601 timestamp string. 143 | */ 144 | function utcTimestamp(): string { 145 | return new Date().toISOString(); 146 | } 147 | 148 | /** 149 | * Format value for display in logs. 150 | * 151 | * @param v Value to format. 152 | * @returns Formatted string representation. 153 | */ 154 | function formatValue(v: unknown): string { 155 | if (typeof v === "string") return v; 156 | if (typeof v === "number" || typeof v === "boolean") return String(v); 157 | return JSON.stringify(v); 158 | } 159 | 160 | /** 161 | * Format structured data as key=value pairs. 162 | * 163 | * @param data Structured data object. 164 | * @returns Formatted string with colored key-value pairs. 165 | */ 166 | function formatData(data: StructuredData): string { 167 | return Object.entries(data) 168 | .map(([k, v]) => `${c.dim(k)}=${c.white(formatValue(v))}`) 169 | .join(" "); 170 | } 171 | 172 | /** 173 | * Shorten tag for display: truncate zone/account IDs to 8 chars. 174 | * 175 | * @param tag Tag string to shorten. 176 | * @returns Shortened tag string. 177 | */ 178 | function shortenTag(tag: string): string { 179 | // Pattern: something:scope:longid:query -> something:scope:shortid:query 180 | return tag.replace(/([a-f0-9]{32})/g, (match) => match.slice(0, 8)); 181 | } 182 | 183 | /** 184 | * Create pretty console reporter for human-readable logs. 185 | * 186 | * @param minLevel Minimum log level to output. 187 | * @returns Reporter object with log method. 188 | */ 189 | function createPrettyReporter(minLevel: LogLevel) { 190 | const minLevelNum = LEVELS[minLevel]; 191 | 192 | return { 193 | log(logObj: LogObject) { 194 | const level = logObj.type as LogLevel; 195 | if (LEVELS[level] === undefined || LEVELS[level] < minLevelNum) return; 196 | 197 | const tag = logObj.tag || "app"; 198 | const colorFn = LEVEL_COLORS[level] || c.white; 199 | const icon = LEVEL_ICONS[level] || "●"; 200 | const args = logObj.args as [string, StructuredData?]; 201 | const msg = args[0]; 202 | const data = args[1]; 203 | 204 | const time = c.dim(formatTime()); 205 | const levelBadge = colorFn(`${icon} ${level.toUpperCase().padEnd(5)}`); 206 | const shortTag = c.dim(shortenTag(tag)); 207 | const suffix = data ? ` ${formatData(data)}` : ""; 208 | 209 | console.log(`${time} ${levelBadge} ${shortTag} ${msg}${suffix}`); 210 | }, 211 | }; 212 | } 213 | 214 | /** 215 | * Create JSON reporter for structured logs. 216 | * 217 | * @param minLevel Minimum log level to output. 218 | * @returns Reporter object with log method. 219 | */ 220 | function createJsonReporter(minLevel: LogLevel) { 221 | const minLevelNum = LEVELS[minLevel]; 222 | 223 | return { 224 | log(logObj: LogObject) { 225 | const level = logObj.type as LogLevel; 226 | if (LEVELS[level] === undefined || LEVELS[level] < minLevelNum) return; 227 | 228 | const tagParts = (logObj.tag || "app").split(":"); 229 | const [logger, ...namespaceParts] = tagParts; 230 | const namespace = 231 | namespaceParts.length > 0 ? namespaceParts.join(":") : undefined; 232 | 233 | const args = logObj.args as [string, StructuredData?]; 234 | const msg = args[0]; 235 | const data = args[1]; 236 | 237 | console.log( 238 | JSON.stringify({ 239 | ts: utcTimestamp(), 240 | logger, 241 | ...(namespace && { namespace }), 242 | level, 243 | msg, 244 | ...data, 245 | }), 246 | ); 247 | }, 248 | }; 249 | } 250 | 251 | // Consola log levels: 0=silent, 1=error, 2=warn, 3=info, 4=debug, 5=trace 252 | const CONSOLA_LEVELS: Record = { 253 | error: 1, 254 | warn: 2, 255 | info: 3, 256 | debug: 4, 257 | }; 258 | 259 | /** 260 | * Create logger instance with specified name and config. 261 | * 262 | * @param name Logger name, normalized to lowercase with underscores. 263 | * @param config Logger configuration. 264 | * @returns Configured logger instance. 265 | */ 266 | export function createLogger(name: string, config: LoggerConfig = {}): Logger { 267 | const format = config.format ?? "pretty"; 268 | const level = config.level ?? "info"; 269 | 270 | const reporter = 271 | format === "json" ? createJsonReporter(level) : createPrettyReporter(level); 272 | 273 | const consola = createConsola({ 274 | level: CONSOLA_LEVELS[level], 275 | reporters: [reporter], 276 | }); 277 | 278 | function makeLogger(tag: string, baseContext: StructuredData = {}): Logger { 279 | const instance = consola.withTag(tag); 280 | 281 | const mergeData = (data?: StructuredData): StructuredData | undefined => { 282 | if (!data && Object.keys(baseContext).length === 0) return undefined; 283 | if (!data) return baseContext; 284 | return { ...baseContext, ...data }; 285 | }; 286 | 287 | return { 288 | debug: (msg, data) => instance.debug(msg, mergeData(data)), 289 | info: (msg, data) => instance.info(msg, mergeData(data)), 290 | warn: (msg, data) => instance.warn(msg, mergeData(data)), 291 | error: (msg, data) => instance.error(msg, mergeData(data)), 292 | child: (ns) => makeLogger(`${tag}:${ns}`, baseContext), 293 | withContext: (ctx) => makeLogger(tag, { ...baseContext, ...ctx }), 294 | }; 295 | } 296 | 297 | const normalizedName = name.toLowerCase().replace(/[ -]/g, "_"); 298 | return makeLogger(normalizedName); 299 | } 300 | 301 | /** 302 | * Create logger config from Cloudflare Worker env. 303 | * 304 | * @param env Environment object with LOG_FORMAT and LOG_LEVEL. 305 | * @returns Logger configuration. 306 | */ 307 | export function configFromEnv(env: { 308 | LOG_FORMAT?: string; 309 | LOG_LEVEL?: string; 310 | }): LoggerConfig { 311 | const format = env.LOG_FORMAT; 312 | const level = env.LOG_LEVEL; 313 | return { 314 | format: format === "json" || format === "pretty" ? format : "pretty", 315 | level: 316 | level === "debug" || 317 | level === "info" || 318 | level === "warn" || 319 | level === "error" 320 | ? level 321 | : "info", 322 | }; 323 | } 324 | -------------------------------------------------------------------------------- /src/durable-objects/MetricCoordinator.ts: -------------------------------------------------------------------------------- 1 | import { DurableObject } from "cloudflare:workers"; 2 | import { getCloudflareMetricsClient } from "../cloudflare/client"; 3 | import { extractErrorInfo } from "../lib/errors"; 4 | import { filterAccountsByIds, parseCommaSeparated } from "../lib/filters"; 5 | import { createLogger, type Logger } from "../lib/logger"; 6 | import type { MetricDefinition } from "../lib/metrics"; 7 | import { serializeToPrometheus } from "../lib/prometheus"; 8 | import { getConfig, type ResolvedConfig } from "../lib/runtime-config"; 9 | import type { Account } from "../lib/types"; 10 | import { AccountMetricCoordinator } from "./AccountMetricCoordinator"; 11 | 12 | const STATE_KEY = "state"; 13 | 14 | type MetricCoordinatorState = { 15 | identifier: string; 16 | accounts: Account[]; 17 | lastAccountFetch: number; 18 | }; 19 | 20 | /** 21 | * Coordinates metrics collection across all Cloudflare accounts and maintains cached account list. 22 | */ 23 | export class MetricCoordinator extends DurableObject { 24 | private state: MetricCoordinatorState | undefined; 25 | 26 | /** 27 | * Gets or creates singleton MetricCoordinator instance. 28 | * 29 | * @param env Worker environment bindings. 30 | * @returns Initialized MetricCoordinator stub. 31 | */ 32 | static async get(env: Env) { 33 | const stub = env.MetricCoordinator.getByName("metric-coordinator"); 34 | await stub.setIdentifier("metric-coordinator"); 35 | return stub; 36 | } 37 | 38 | /** 39 | * Constructs MetricCoordinator and initializes state from storage. 40 | * 41 | * @param ctx Durable Object state. 42 | * @param env Worker environment bindings. 43 | */ 44 | constructor(ctx: DurableObjectState, env: Env) { 45 | super(ctx, env); 46 | ctx.blockConcurrencyWhile(async () => { 47 | this.state = await ctx.storage.get(STATE_KEY); 48 | }); 49 | } 50 | 51 | /** 52 | * Creates logger instance with resolved configuration. 53 | * 54 | * @param config Resolved runtime configuration. 55 | * @returns Logger instance. 56 | */ 57 | private createLogger(config: ResolvedConfig): Logger { 58 | return createLogger("metric_coordinator", { 59 | format: config.logFormat, 60 | level: config.logLevel, 61 | }); 62 | } 63 | 64 | /** 65 | * Initializes coordinator state if not already set. 66 | * 67 | * @param id Unique identifier for this coordinator instance. 68 | */ 69 | async setIdentifier(id: string): Promise { 70 | if (this.state !== undefined) { 71 | return; 72 | } 73 | this.state = { identifier: id, accounts: [], lastAccountFetch: 0 }; 74 | await this.ctx.storage.put(STATE_KEY, this.state); 75 | } 76 | 77 | /** 78 | * Gets coordinator state. 79 | * 80 | * @returns Current coordinator state. 81 | * @throws {Error} When state not initialized. 82 | */ 83 | private getState(): MetricCoordinatorState { 84 | if (this.state === undefined) { 85 | throw new Error("State not initialized"); 86 | } 87 | return this.state; 88 | } 89 | 90 | /** 91 | * Refreshes accounts from Cloudflare API if cache expired. 92 | * 93 | * @param config Resolved runtime configuration. 94 | * @param logger Logger instance. 95 | * @returns Cached or refreshed account list. 96 | */ 97 | private async refreshAccountsIfStale( 98 | config: ResolvedConfig, 99 | logger: Logger, 100 | ): Promise { 101 | const state = this.getState(); 102 | const ttlMs = config.accountListCacheTtlSeconds * 1000; 103 | 104 | if ( 105 | state.accounts.length > 0 && 106 | Date.now() - state.lastAccountFetch < ttlMs 107 | ) { 108 | return state.accounts; 109 | } 110 | 111 | const client = getCloudflareMetricsClient(this.env); 112 | logger.info("Refreshing account list"); 113 | const allAccounts = await client.getAccounts(); 114 | 115 | // Filter accounts if whitelist is set 116 | const cfAccountsSet = 117 | config.cfAccounts !== null 118 | ? parseCommaSeparated(config.cfAccounts) 119 | : null; 120 | const accounts = 121 | cfAccountsSet !== null 122 | ? filterAccountsByIds(allAccounts, cfAccountsSet) 123 | : allAccounts; 124 | 125 | this.state = { 126 | ...state, 127 | accounts, 128 | lastAccountFetch: Date.now(), 129 | }; 130 | await this.ctx.storage.put(STATE_KEY, this.state); 131 | 132 | logger.info("Accounts cached", { 133 | total: allAccounts.length, 134 | filtered: accounts.length, 135 | }); 136 | return accounts; 137 | } 138 | 139 | /** 140 | * Collects metrics from all accounts and serializes to Prometheus format. 141 | * 142 | * @returns Prometheus-formatted metrics string. 143 | */ 144 | async export(): Promise { 145 | const config = await getConfig(this.env); 146 | const logger = this.createLogger(config); 147 | 148 | logger.info("Collecting metrics"); 149 | const accounts = await this.refreshAccountsIfStale(config, logger); 150 | 151 | if (accounts.length === 0) { 152 | logger.warn("No accounts found"); 153 | return ""; 154 | } 155 | 156 | logger.info("Exporting metrics", { account_count: accounts.length }); 157 | 158 | // Track errors by account and error code 159 | const errorsByAccount: Map = 160 | new Map(); 161 | 162 | const results = await Promise.all( 163 | accounts.map(async (account) => { 164 | try { 165 | const coordinator = await AccountMetricCoordinator.get( 166 | account.id, 167 | account.name, 168 | this.env, 169 | ); 170 | return await coordinator.export(); 171 | } catch (error) { 172 | const info = extractErrorInfo(error); 173 | logger.error("Failed to export account", { 174 | account_id: account.id, 175 | error_code: info.code, 176 | error: info.message, 177 | ...(info.stack && { stack: info.stack }), 178 | }); 179 | 180 | // Track error for metrics 181 | const accountErrors = errorsByAccount.get(account.id) ?? []; 182 | const existing = accountErrors.find((e) => e.code === info.code); 183 | if (existing) { 184 | existing.count++; 185 | } else { 186 | accountErrors.push({ code: info.code, count: 1 }); 187 | } 188 | errorsByAccount.set(account.id, accountErrors); 189 | 190 | return { 191 | metrics: [], 192 | zoneCounts: { 193 | total: 0, 194 | filtered: 0, 195 | processed: 0, 196 | skippedFreeTier: 0, 197 | }, 198 | }; 199 | } 200 | }), 201 | ); 202 | 203 | // Aggregate stats 204 | const zoneCounts = { 205 | total: 0, 206 | filtered: 0, 207 | processed: 0, 208 | skippedFreeTier: 0, 209 | }; 210 | const allMetrics: MetricDefinition[] = []; 211 | for (const result of results) { 212 | allMetrics.push(...result.metrics); 213 | zoneCounts.total += result.zoneCounts.total; 214 | zoneCounts.filtered += result.zoneCounts.filtered; 215 | zoneCounts.processed += result.zoneCounts.processed; 216 | zoneCounts.skippedFreeTier += result.zoneCounts.skippedFreeTier; 217 | } 218 | 219 | // Add exporter info metrics 220 | const exporterMetrics = this.buildExporterInfoMetrics( 221 | accounts.length, 222 | zoneCounts, 223 | errorsByAccount, 224 | ); 225 | 226 | const metricsDenylist = parseCommaSeparated(config.metricsDenylist); 227 | return serializeToPrometheus([...exporterMetrics, ...allMetrics], { 228 | denylist: metricsDenylist, 229 | excludeLabels: config.excludeHost ? new Set(["host"]) : undefined, 230 | }); 231 | } 232 | 233 | /** 234 | * Builds exporter health and discovery metrics. 235 | * 236 | * @param accountCount Number of accounts discovered. 237 | * @param zoneCounts Zone counts (total, filtered, processed, skippedFreeTier). 238 | * @param errorsByAccount Errors by account and error code. 239 | * @returns Exporter info metrics. 240 | */ 241 | private buildExporterInfoMetrics( 242 | accountCount: number, 243 | zoneCounts: { 244 | total: number; 245 | filtered: number; 246 | processed: number; 247 | skippedFreeTier: number; 248 | }, 249 | errorsByAccount: Map, 250 | ): MetricDefinition[] { 251 | const metrics: MetricDefinition[] = [ 252 | { 253 | name: "cloudflare_exporter_up", 254 | help: "Exporter health", 255 | type: "gauge", 256 | values: [{ labels: {}, value: 1 }], 257 | }, 258 | { 259 | name: "cloudflare_accounts", 260 | help: "Total accounts discovered", 261 | type: "gauge", 262 | values: [{ labels: {}, value: accountCount }], 263 | }, 264 | { 265 | name: "cloudflare_zones", 266 | help: "Total zones before filtering", 267 | type: "gauge", 268 | values: [{ labels: {}, value: zoneCounts.total }], 269 | }, 270 | { 271 | name: "cloudflare_zones_filtered", 272 | help: "Zones after whitelist filter", 273 | type: "gauge", 274 | values: [{ labels: {}, value: zoneCounts.filtered }], 275 | }, 276 | { 277 | name: "cloudflare_zones_processed", 278 | help: "Zones successfully processed", 279 | type: "gauge", 280 | values: [{ labels: {}, value: zoneCounts.processed }], 281 | }, 282 | { 283 | name: "cloudflare_zones_skipped_free_tier", 284 | help: "Zones skipped due to free tier plan (no GraphQL analytics access)", 285 | type: "gauge", 286 | values: [{ labels: {}, value: zoneCounts.skippedFreeTier }], 287 | }, 288 | ]; 289 | 290 | // Add error metrics if any errors occurred 291 | if (errorsByAccount.size > 0) { 292 | const errorsMetric: MetricDefinition = { 293 | name: "cloudflare_exporter_errors_total", 294 | help: "Total errors during metric collection by account and error code", 295 | type: "counter", 296 | values: [], 297 | }; 298 | 299 | for (const [accountId, errors] of errorsByAccount) { 300 | for (const { code, count } of errors) { 301 | errorsMetric.values.push({ 302 | labels: { account_id: accountId, error_code: code }, 303 | value: count, 304 | }); 305 | } 306 | } 307 | 308 | metrics.push(errorsMetric); 309 | } 310 | 311 | return metrics; 312 | } 313 | } 314 | -------------------------------------------------------------------------------- /src/lib/errors.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Error codes for categorization and alerting. 3 | */ 4 | export const ErrorCode = { 5 | // API/Network 6 | API_RATE_LIMITED: "API_RATE_LIMITED", 7 | API_TIMEOUT: "API_TIMEOUT", 8 | API_UNAVAILABLE: "API_UNAVAILABLE", 9 | API_AUTH_FAILED: "API_AUTH_FAILED", 10 | 11 | // GraphQL 12 | GRAPHQL_ERROR: "GRAPHQL_ERROR", 13 | GRAPHQL_FIELD_ACCESS: "GRAPHQL_FIELD_ACCESS", 14 | 15 | // Config 16 | CONFIG_INVALID: "CONFIG_INVALID", 17 | CONFIG_PARSE_ERROR: "CONFIG_PARSE_ERROR", 18 | 19 | // State 20 | STATE_NOT_INITIALIZED: "STATE_NOT_INITIALIZED", 21 | 22 | // Validation 23 | VALIDATION_ERROR: "VALIDATION_ERROR", 24 | 25 | // Timeout 26 | TIMEOUT: "TIMEOUT", 27 | 28 | // Unknown 29 | UNKNOWN: "UNKNOWN", 30 | } as const; 31 | 32 | /** 33 | * Error code type. 34 | */ 35 | export type ErrorCode = (typeof ErrorCode)[keyof typeof ErrorCode]; 36 | 37 | /** 38 | * Error context type. 39 | */ 40 | type ErrorContext = Record; 41 | 42 | /** 43 | * CloudflarePrometheusError options. 44 | */ 45 | type CloudflarePrometheusErrorOptions = ErrorOptions & { 46 | context?: ErrorContext; 47 | retryable?: boolean; 48 | }; 49 | 50 | /** 51 | * Base error class with cause chaining, error codes, and structured logging support. 52 | */ 53 | export class CloudflarePrometheusError extends Error { 54 | readonly code: ErrorCode; 55 | readonly context: ErrorContext; 56 | readonly timestamp: string; 57 | readonly retryable: boolean; 58 | 59 | /** 60 | * Create a CloudflarePrometheusError. 61 | * 62 | * @param message Error message. 63 | * @param code Error code. 64 | * @param options Error options. 65 | */ 66 | constructor( 67 | message: string, 68 | code: ErrorCode, 69 | options?: CloudflarePrometheusErrorOptions, 70 | ) { 71 | super(message, options); 72 | 73 | // Fix prototype chain for instanceof checks 74 | Object.setPrototypeOf(this, new.target.prototype); 75 | 76 | this.name = this.constructor.name; 77 | this.code = code; 78 | this.context = options?.context ?? {}; 79 | this.timestamp = new Date().toISOString(); 80 | this.retryable = options?.retryable ?? false; 81 | 82 | // Append cause stack if available 83 | if (options?.cause instanceof Error) { 84 | this.stack = `${this.stack}\nCaused by: ${options.cause.stack}`; 85 | } 86 | } 87 | 88 | /** 89 | * Convert to structured data for logging. 90 | * 91 | * @returns Structured error context. 92 | */ 93 | toStructuredData(): ErrorContext { 94 | return { 95 | error_code: this.code, 96 | error_message: this.message, 97 | error_name: this.name, 98 | error_retryable: this.retryable, 99 | ...this.context, 100 | }; 101 | } 102 | } 103 | 104 | /** 105 | * API errors (rate limits, unavailable, auth failures). 106 | */ 107 | export class ApiError extends CloudflarePrometheusError { 108 | readonly statusCode?: number; 109 | 110 | /** 111 | * Create an ApiError. 112 | * 113 | * @param message Error message. 114 | * @param options Error options. 115 | */ 116 | constructor( 117 | message: string, 118 | options?: CloudflarePrometheusErrorOptions & { statusCode?: number }, 119 | ) { 120 | const statusCode = options?.statusCode; 121 | let code: ErrorCode; 122 | let retryable = false; 123 | 124 | if (statusCode === 429) { 125 | code = ErrorCode.API_RATE_LIMITED; 126 | retryable = true; 127 | } else if (statusCode === 401 || statusCode === 403) { 128 | code = ErrorCode.API_AUTH_FAILED; 129 | } else if (statusCode !== undefined && statusCode >= 500) { 130 | code = ErrorCode.API_UNAVAILABLE; 131 | retryable = true; 132 | } else { 133 | code = ErrorCode.API_UNAVAILABLE; 134 | } 135 | 136 | super(message, code, { ...options, retryable }); 137 | this.statusCode = statusCode; 138 | } 139 | 140 | /** 141 | * Convert to structured data for logging. 142 | * 143 | * @returns Structured error context. 144 | */ 145 | override toStructuredData(): ErrorContext { 146 | return { 147 | ...super.toStructuredData(), 148 | ...(this.statusCode !== undefined && { status_code: this.statusCode }), 149 | }; 150 | } 151 | } 152 | 153 | /** 154 | * GraphQL error detail. 155 | */ 156 | type GraphQLErrorDetail = { 157 | message: string; 158 | path?: ReadonlyArray; 159 | extensions?: Record; 160 | }; 161 | 162 | /** 163 | * GraphQL query errors with access to underlying error details. 164 | */ 165 | export class GraphQLError extends CloudflarePrometheusError { 166 | readonly graphqlErrors: GraphQLErrorDetail[]; 167 | 168 | /** 169 | * Create a GraphQLError. 170 | * 171 | * @param message Error message. 172 | * @param graphqlErrors GraphQL error details. 173 | * @param options Error options. 174 | */ 175 | constructor( 176 | message: string, 177 | graphqlErrors: GraphQLErrorDetail[] = [], 178 | options?: CloudflarePrometheusErrorOptions, 179 | ) { 180 | const hasFieldAccessError = graphqlErrors.some( 181 | (e) => 182 | e.message.includes("does not have access") || 183 | e.extensions?.code === "FORBIDDEN", 184 | ); 185 | const code = hasFieldAccessError 186 | ? ErrorCode.GRAPHQL_FIELD_ACCESS 187 | : ErrorCode.GRAPHQL_ERROR; 188 | 189 | super(message, code, options); 190 | this.graphqlErrors = graphqlErrors; 191 | } 192 | 193 | /** 194 | * Convert to structured data for logging. 195 | * 196 | * @returns Structured error context. 197 | */ 198 | override toStructuredData(): ErrorContext { 199 | return { 200 | ...super.toStructuredData(), 201 | graphql_error_count: this.graphqlErrors.length, 202 | graphql_paths: this.graphqlErrors 203 | .map((e) => e.path?.join(".")) 204 | .filter(Boolean), 205 | }; 206 | } 207 | } 208 | 209 | /** 210 | * Configuration parsing/validation errors. 211 | */ 212 | export class ConfigError extends CloudflarePrometheusError { 213 | readonly issues?: Array<{ path: string; message: string }>; 214 | 215 | /** 216 | * Create a ConfigError. 217 | * 218 | * @param message Error message. 219 | * @param options Error options. 220 | */ 221 | constructor( 222 | message: string, 223 | options?: CloudflarePrometheusErrorOptions & { 224 | issues?: Array<{ path: string; message: string }>; 225 | }, 226 | ) { 227 | const code = message.includes("parse") 228 | ? ErrorCode.CONFIG_PARSE_ERROR 229 | : ErrorCode.CONFIG_INVALID; 230 | super(message, code, options); 231 | this.issues = options?.issues; 232 | } 233 | 234 | /** 235 | * Convert to structured data for logging. 236 | * 237 | * @returns Structured error context. 238 | */ 239 | override toStructuredData(): ErrorContext { 240 | return { 241 | ...super.toStructuredData(), 242 | ...(this.issues && { validation_issues: this.issues }), 243 | }; 244 | } 245 | } 246 | 247 | /** 248 | * State not initialized (DO not ready). 249 | */ 250 | export class StateNotInitializedError extends CloudflarePrometheusError { 251 | /** 252 | * Create a StateNotInitializedError. 253 | * 254 | * @param component Component name. 255 | * @param options Error options. 256 | */ 257 | constructor( 258 | component: string, 259 | options?: Omit, 260 | ) { 261 | super( 262 | `State not initialized - initialize() must be called first`, 263 | ErrorCode.STATE_NOT_INITIALIZED, 264 | { 265 | ...options, 266 | context: { component }, 267 | }, 268 | ); 269 | } 270 | } 271 | 272 | /** 273 | * Operation timeout. 274 | */ 275 | export class TimeoutError extends CloudflarePrometheusError { 276 | readonly timeoutMs: number; 277 | readonly operation: string; 278 | 279 | /** 280 | * Create a TimeoutError. 281 | * 282 | * @param operation Operation name. 283 | * @param timeoutMs Timeout in milliseconds. 284 | * @param options Error options. 285 | */ 286 | constructor( 287 | operation: string, 288 | timeoutMs: number, 289 | options?: CloudflarePrometheusErrorOptions, 290 | ) { 291 | super(`${operation} timed out after ${timeoutMs}ms`, ErrorCode.TIMEOUT, { 292 | ...options, 293 | retryable: true, 294 | context: { ...options?.context, operation, timeout_ms: timeoutMs }, 295 | }); 296 | this.timeoutMs = timeoutMs; 297 | this.operation = operation; 298 | } 299 | } 300 | 301 | /** 302 | * Race promise against timeout with proper cleanup. 303 | * 304 | * @param promise Promise to race. 305 | * @param ms Timeout in milliseconds. 306 | * @param operation Operation name. 307 | * @returns Discriminated union for type-safe handling. 308 | */ 309 | export async function withTimeout( 310 | promise: Promise, 311 | ms: number, 312 | operation = "Operation", 313 | ): Promise<{ ok: true; value: T } | { ok: false; error: TimeoutError }> { 314 | let timeoutId: ReturnType | undefined; 315 | 316 | const timeoutPromise = new Promise((_, reject) => { 317 | timeoutId = setTimeout(() => reject(new TimeoutError(operation, ms)), ms); 318 | }); 319 | 320 | try { 321 | const value = await Promise.race([promise, timeoutPromise]); 322 | return { ok: true, value }; 323 | } catch (err) { 324 | if (err instanceof TimeoutError) { 325 | return { ok: false, error: err }; 326 | } 327 | throw err; 328 | } finally { 329 | if (timeoutId !== undefined) { 330 | clearTimeout(timeoutId); 331 | } 332 | } 333 | } 334 | 335 | /** 336 | * Validation error (Zod or other). 337 | */ 338 | export class ValidationError extends CloudflarePrometheusError { 339 | readonly field?: string; 340 | 341 | /** 342 | * Create a ValidationError. 343 | * 344 | * @param message Error message. 345 | * @param options Error options. 346 | */ 347 | constructor( 348 | message: string, 349 | options?: CloudflarePrometheusErrorOptions & { field?: string }, 350 | ) { 351 | super(message, ErrorCode.VALIDATION_ERROR, options); 352 | this.field = options?.field; 353 | } 354 | 355 | /** 356 | * Convert to structured data for logging. 357 | * 358 | * @returns Structured error context. 359 | */ 360 | override toStructuredData(): ErrorContext { 361 | return { 362 | ...super.toStructuredData(), 363 | ...(this.field && { field: this.field }), 364 | }; 365 | } 366 | } 367 | 368 | /** 369 | * Extract structured error info from any error type. 370 | * 371 | * @param error Error to extract info from. 372 | * @returns Structured error info. 373 | */ 374 | export function extractErrorInfo(error: unknown): { 375 | message: string; 376 | stack?: string; 377 | code: ErrorCode; 378 | context: ErrorContext; 379 | retryable: boolean; 380 | } { 381 | if (error instanceof CloudflarePrometheusError) { 382 | return { 383 | message: error.message, 384 | stack: error.stack, 385 | code: error.code, 386 | context: error.context, 387 | retryable: error.retryable, 388 | }; 389 | } 390 | 391 | if (error instanceof Error) { 392 | return { 393 | message: error.message, 394 | stack: error.stack, 395 | code: ErrorCode.UNKNOWN, 396 | context: {}, 397 | retryable: false, 398 | }; 399 | } 400 | 401 | return { 402 | message: String(error), 403 | code: ErrorCode.UNKNOWN, 404 | context: {}, 405 | retryable: false, 406 | }; 407 | } 408 | 409 | /** 410 | * Check if an error is retryable. 411 | * 412 | * @param error Error to check. 413 | * @returns True if retryable. 414 | */ 415 | export function isRetryable(error: unknown): boolean { 416 | if (error instanceof CloudflarePrometheusError) { 417 | return error.retryable; 418 | } 419 | 420 | // Network errors are generally retryable 421 | if (error instanceof TypeError && error.message.includes("fetch")) { 422 | return true; 423 | } 424 | 425 | return false; 426 | } 427 | 428 | /** 429 | * Wrap an unknown error as a CloudflarePrometheusError. 430 | * 431 | * @param error Error to wrap. 432 | * @param message Error message. 433 | * @param code Error code. 434 | * @param context Error context. 435 | * @returns Wrapped error. 436 | */ 437 | export function wrapError( 438 | error: unknown, 439 | message: string, 440 | code: ErrorCode = ErrorCode.UNKNOWN, 441 | context?: ErrorContext, 442 | ): CloudflarePrometheusError { 443 | if (error instanceof CloudflarePrometheusError) { 444 | // Already our error type, just add context if needed 445 | if (context) { 446 | return new CloudflarePrometheusError(message, error.code, { 447 | cause: error, 448 | context: { ...error.context, ...context }, 449 | retryable: error.retryable, 450 | }); 451 | } 452 | return error; 453 | } 454 | 455 | return new CloudflarePrometheusError(message, code, { 456 | cause: error instanceof Error ? error : undefined, 457 | context, 458 | }); 459 | } 460 | -------------------------------------------------------------------------------- /src/lib/runtime-config.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | 3 | /** KV storage key for configuration overrides. */ 4 | const KV_KEY = "overrides"; 5 | 6 | /** 7 | * Zod schema for valid configuration key names. 8 | */ 9 | export const ConfigKeySchema = z.enum([ 10 | // Timing/limits 11 | "queryLimit", 12 | "scrapeDelaySeconds", 13 | "timeWindowSeconds", 14 | "metricRefreshIntervalSeconds", 15 | // Cache TTLs 16 | "accountListCacheTtlSeconds", 17 | "zoneListCacheTtlSeconds", 18 | "sslCertsCacheTtlSeconds", 19 | "healthCheckCacheTtlSeconds", 20 | // Logging 21 | "logFormat", 22 | "logLevel", 23 | // Filters/whitelists 24 | "cfAccounts", 25 | "cfZones", 26 | "cfFreeTierAccounts", 27 | "metricsDenylist", 28 | // Output options 29 | "excludeHost", 30 | "httpStatusGroup", 31 | ]); 32 | 33 | /** 34 | * Union type of all valid configuration key names. 35 | */ 36 | export type ConfigKey = z.infer; 37 | 38 | /** 39 | * Zod schemas for individual configuration values by key. 40 | */ 41 | const ConfigValueSchemas = { 42 | queryLimit: z.number().int().positive(), 43 | scrapeDelaySeconds: z.number().int().nonnegative(), 44 | timeWindowSeconds: z.number().int().positive(), 45 | metricRefreshIntervalSeconds: z.number().int().positive(), 46 | accountListCacheTtlSeconds: z.number().int().nonnegative(), 47 | zoneListCacheTtlSeconds: z.number().int().nonnegative(), 48 | sslCertsCacheTtlSeconds: z.number().int().nonnegative(), 49 | healthCheckCacheTtlSeconds: z.number().int().nonnegative(), 50 | logFormat: z.enum(["json", "pretty"]), 51 | logLevel: z.enum(["debug", "info", "warn", "error"]), 52 | cfAccounts: z.string().nullable(), 53 | cfZones: z.string().nullable(), 54 | cfFreeTierAccounts: z.string(), 55 | metricsDenylist: z.string(), 56 | excludeHost: z.boolean(), 57 | httpStatusGroup: z.boolean(), 58 | } as const; 59 | 60 | /** 61 | * Zod schema for partial configuration overrides (all fields optional). 62 | */ 63 | export const ConfigOverridesSchema = z 64 | .object({ 65 | queryLimit: ConfigValueSchemas.queryLimit.optional(), 66 | scrapeDelaySeconds: ConfigValueSchemas.scrapeDelaySeconds.optional(), 67 | timeWindowSeconds: ConfigValueSchemas.timeWindowSeconds.optional(), 68 | metricRefreshIntervalSeconds: 69 | ConfigValueSchemas.metricRefreshIntervalSeconds.optional(), 70 | accountListCacheTtlSeconds: 71 | ConfigValueSchemas.accountListCacheTtlSeconds.optional(), 72 | zoneListCacheTtlSeconds: 73 | ConfigValueSchemas.zoneListCacheTtlSeconds.optional(), 74 | sslCertsCacheTtlSeconds: 75 | ConfigValueSchemas.sslCertsCacheTtlSeconds.optional(), 76 | healthCheckCacheTtlSeconds: 77 | ConfigValueSchemas.healthCheckCacheTtlSeconds.optional(), 78 | logFormat: ConfigValueSchemas.logFormat.optional(), 79 | logLevel: ConfigValueSchemas.logLevel.optional(), 80 | cfAccounts: ConfigValueSchemas.cfAccounts.optional(), 81 | cfZones: ConfigValueSchemas.cfZones.optional(), 82 | cfFreeTierAccounts: ConfigValueSchemas.cfFreeTierAccounts.optional(), 83 | metricsDenylist: ConfigValueSchemas.metricsDenylist.optional(), 84 | excludeHost: ConfigValueSchemas.excludeHost.optional(), 85 | httpStatusGroup: ConfigValueSchemas.httpStatusGroup.optional(), 86 | }) 87 | .readonly(); 88 | 89 | /** 90 | * Partial configuration overrides stored in KV. 91 | */ 92 | export type ConfigOverrides = z.infer; 93 | 94 | /** 95 | * Zod schema for fully resolved configuration (all fields required). 96 | */ 97 | export const ResolvedConfigSchema = z 98 | .object({ 99 | queryLimit: ConfigValueSchemas.queryLimit, 100 | scrapeDelaySeconds: ConfigValueSchemas.scrapeDelaySeconds, 101 | timeWindowSeconds: ConfigValueSchemas.timeWindowSeconds, 102 | metricRefreshIntervalSeconds: 103 | ConfigValueSchemas.metricRefreshIntervalSeconds, 104 | accountListCacheTtlSeconds: ConfigValueSchemas.accountListCacheTtlSeconds, 105 | zoneListCacheTtlSeconds: ConfigValueSchemas.zoneListCacheTtlSeconds, 106 | sslCertsCacheTtlSeconds: ConfigValueSchemas.sslCertsCacheTtlSeconds, 107 | healthCheckCacheTtlSeconds: ConfigValueSchemas.healthCheckCacheTtlSeconds, 108 | logFormat: ConfigValueSchemas.logFormat, 109 | logLevel: ConfigValueSchemas.logLevel, 110 | cfAccounts: ConfigValueSchemas.cfAccounts, 111 | cfZones: ConfigValueSchemas.cfZones, 112 | cfFreeTierAccounts: ConfigValueSchemas.cfFreeTierAccounts, 113 | metricsDenylist: ConfigValueSchemas.metricsDenylist, 114 | excludeHost: ConfigValueSchemas.excludeHost, 115 | httpStatusGroup: ConfigValueSchemas.httpStatusGroup, 116 | }) 117 | .readonly(); 118 | 119 | /** 120 | * Fully resolved configuration with all fields populated. 121 | */ 122 | export type ResolvedConfig = z.infer; 123 | 124 | /** 125 | * Optional environment variables not defined in wrangler.jsonc. 126 | */ 127 | type OptionalEnvVars = { 128 | METRICS_DENYLIST?: string; 129 | CF_ACCOUNTS?: string; 130 | CF_ZONES?: string; 131 | CF_FREE_TIER_ACCOUNTS?: string; 132 | HEALTH_CHECK_CACHE_TTL_SECONDS?: string; 133 | }; 134 | 135 | /** 136 | * Gets default configuration values from environment variables. 137 | * 138 | * @param env Worker environment bindings. 139 | * @returns Resolved configuration with defaults applied. 140 | */ 141 | export function getEnvDefaults(env: Env): ResolvedConfig { 142 | const optionalEnv = env as Env & OptionalEnvVars; 143 | return { 144 | queryLimit: z.coerce.number().catch(10000).parse(env.QUERY_LIMIT), 145 | scrapeDelaySeconds: z.coerce 146 | .number() 147 | .catch(300) 148 | .parse(env.SCRAPE_DELAY_SECONDS), 149 | timeWindowSeconds: z.coerce 150 | .number() 151 | .catch(60) 152 | .parse(env.TIME_WINDOW_SECONDS), 153 | metricRefreshIntervalSeconds: z.coerce 154 | .number() 155 | .catch(60) 156 | .parse(env.METRIC_REFRESH_INTERVAL_SECONDS), 157 | accountListCacheTtlSeconds: z.coerce 158 | .number() 159 | .catch(600) 160 | .parse(env.ACCOUNT_LIST_CACHE_TTL_SECONDS), 161 | zoneListCacheTtlSeconds: z.coerce 162 | .number() 163 | .catch(1800) 164 | .parse(env.ZONE_LIST_CACHE_TTL_SECONDS), 165 | sslCertsCacheTtlSeconds: z.coerce 166 | .number() 167 | .catch(1800) 168 | .parse(env.SSL_CERTS_CACHE_TTL_SECONDS), 169 | healthCheckCacheTtlSeconds: z.coerce 170 | .number() 171 | .catch(10) 172 | .parse(optionalEnv.HEALTH_CHECK_CACHE_TTL_SECONDS), 173 | logFormat: z.enum(["json", "pretty"]).catch("pretty").parse(env.LOG_FORMAT), 174 | logLevel: z 175 | .enum(["debug", "info", "warn", "error"]) 176 | .catch("info") 177 | .parse(env.LOG_LEVEL), 178 | cfAccounts: optionalEnv.CF_ACCOUNTS?.trim() || null, 179 | cfZones: optionalEnv.CF_ZONES?.trim() || null, 180 | cfFreeTierAccounts: optionalEnv.CF_FREE_TIER_ACCOUNTS?.trim() ?? "", 181 | metricsDenylist: optionalEnv.METRICS_DENYLIST?.trim() ?? "", 182 | excludeHost: z.coerce.boolean().catch(false).parse(env.EXCLUDE_HOST), 183 | httpStatusGroup: z.coerce 184 | .boolean() 185 | .catch(false) 186 | .parse(env.CF_HTTP_STATUS_GROUP), 187 | }; 188 | } 189 | 190 | /** 191 | * Reads configuration overrides from KV storage. 192 | * Returns empty object on parse errors or missing data. 193 | * 194 | * @param env Worker environment bindings. 195 | * @returns Configuration overrides or empty object. 196 | */ 197 | async function readOverrides(env: Env): Promise { 198 | const raw = await env.CONFIG_KV.get(KV_KEY); 199 | if (!raw) return {}; 200 | try { 201 | const parsed: unknown = JSON.parse(raw); 202 | const result = ConfigOverridesSchema.safeParse(parsed); 203 | if (!result.success) { 204 | console.error("Invalid config overrides in KV, using defaults", { 205 | error: result.error.message, 206 | }); 207 | return {}; 208 | } 209 | return result.data; 210 | } catch { 211 | console.error("Failed to parse config overrides from KV, using defaults"); 212 | return {}; 213 | } 214 | } 215 | 216 | /** 217 | * Writes configuration overrides to KV storage. 218 | * 219 | * @param env Worker environment bindings. 220 | * @param overrides Configuration overrides to persist. 221 | */ 222 | async function writeOverrides( 223 | env: Env, 224 | overrides: ConfigOverrides, 225 | ): Promise { 226 | await env.CONFIG_KV.put(KV_KEY, JSON.stringify(overrides)); 227 | } 228 | 229 | /** 230 | * Merges configuration overrides with environment defaults. 231 | * 232 | * @param defaults Default configuration from environment. 233 | * @param overrides Partial overrides from KV storage. 234 | * @returns Fully resolved configuration. 235 | */ 236 | function mergeConfig( 237 | defaults: ResolvedConfig, 238 | overrides: ConfigOverrides, 239 | ): ResolvedConfig { 240 | return { 241 | queryLimit: overrides.queryLimit ?? defaults.queryLimit, 242 | scrapeDelaySeconds: 243 | overrides.scrapeDelaySeconds ?? defaults.scrapeDelaySeconds, 244 | timeWindowSeconds: 245 | overrides.timeWindowSeconds ?? defaults.timeWindowSeconds, 246 | metricRefreshIntervalSeconds: 247 | overrides.metricRefreshIntervalSeconds ?? 248 | defaults.metricRefreshIntervalSeconds, 249 | accountListCacheTtlSeconds: 250 | overrides.accountListCacheTtlSeconds ?? 251 | defaults.accountListCacheTtlSeconds, 252 | zoneListCacheTtlSeconds: 253 | overrides.zoneListCacheTtlSeconds ?? defaults.zoneListCacheTtlSeconds, 254 | sslCertsCacheTtlSeconds: 255 | overrides.sslCertsCacheTtlSeconds ?? defaults.sslCertsCacheTtlSeconds, 256 | healthCheckCacheTtlSeconds: 257 | overrides.healthCheckCacheTtlSeconds ?? 258 | defaults.healthCheckCacheTtlSeconds, 259 | logFormat: overrides.logFormat ?? defaults.logFormat, 260 | logLevel: overrides.logLevel ?? defaults.logLevel, 261 | cfAccounts: 262 | overrides.cfAccounts !== undefined 263 | ? overrides.cfAccounts 264 | : defaults.cfAccounts, 265 | cfZones: 266 | overrides.cfZones !== undefined ? overrides.cfZones : defaults.cfZones, 267 | cfFreeTierAccounts: 268 | overrides.cfFreeTierAccounts ?? defaults.cfFreeTierAccounts, 269 | metricsDenylist: overrides.metricsDenylist ?? defaults.metricsDenylist, 270 | excludeHost: overrides.excludeHost ?? defaults.excludeHost, 271 | httpStatusGroup: overrides.httpStatusGroup ?? defaults.httpStatusGroup, 272 | }; 273 | } 274 | 275 | /** 276 | * Gets resolved configuration by merging KV overrides with environment defaults. 277 | * 278 | * @param env Worker environment bindings. 279 | * @returns Fully resolved configuration. 280 | */ 281 | export async function getConfig(env: Env): Promise { 282 | const defaults = getEnvDefaults(env); 283 | const overrides = await readOverrides(env); 284 | return mergeConfig(defaults, overrides); 285 | } 286 | 287 | /** 288 | * Gets a single configuration key value. 289 | * 290 | * @param env Worker environment bindings. 291 | * @param key Configuration key to retrieve. 292 | * @returns Value for the specified configuration key. 293 | */ 294 | export async function getConfigKey( 295 | env: Env, 296 | key: K, 297 | ): Promise { 298 | const config = await getConfig(env); 299 | return config[key]; 300 | } 301 | 302 | /** 303 | * Validates a value for a specific configuration key. 304 | * 305 | * @param key Configuration key to validate against. 306 | * @param value Value to validate. 307 | * @returns Validation result with parsed data or Zod error. 308 | */ 309 | export function validateConfigValue( 310 | key: ConfigKey, 311 | value: unknown, 312 | ): { success: true; data: unknown } | { success: false; error: z.ZodError } { 313 | return ConfigValueSchemas[key].safeParse(value); 314 | } 315 | 316 | /** 317 | * Result type for setConfigKey operation. 318 | */ 319 | type SetConfigKeyResult = 320 | | { success: true; config: ResolvedConfig } 321 | | { success: false; error: z.ZodError }; 322 | 323 | /** 324 | * Sets a single configuration key override with validation. 325 | * 326 | * @param env Worker environment bindings. 327 | * @param key Configuration key to set. 328 | * @param value Value to set for the key. 329 | * @returns Result with updated config or validation error. 330 | */ 331 | export async function setConfigKey( 332 | env: Env, 333 | key: ConfigKey, 334 | value: unknown, 335 | ): Promise { 336 | const result = ConfigValueSchemas[key].safeParse(value); 337 | if (!result.success) { 338 | return { success: false, error: result.error }; 339 | } 340 | const overrides = await readOverrides(env); 341 | const updated = { ...overrides, [key]: result.data }; 342 | await writeOverrides(env, updated); 343 | return { 344 | success: true, 345 | config: mergeConfig(getEnvDefaults(env), updated), 346 | }; 347 | } 348 | 349 | /** 350 | * Resets a single configuration key to its environment default. 351 | * 352 | * @param env Worker environment bindings. 353 | * @param key Configuration key to reset. 354 | * @returns Resolved configuration after reset. 355 | */ 356 | export async function resetConfigKey( 357 | env: Env, 358 | key: ConfigKey, 359 | ): Promise { 360 | const overrides = await readOverrides(env); 361 | const { [key]: _, ...remaining } = overrides; 362 | await writeOverrides(env, remaining); 363 | return mergeConfig(getEnvDefaults(env), remaining); 364 | } 365 | 366 | /** 367 | * Resets all configuration overrides to environment defaults. 368 | * 369 | * @param env Worker environment bindings. 370 | * @returns Resolved configuration with only environment defaults. 371 | */ 372 | export async function resetAllConfig(env: Env): Promise { 373 | await env.CONFIG_KV.delete(KV_KEY); 374 | return getEnvDefaults(env); 375 | } 376 | -------------------------------------------------------------------------------- /src/durable-objects/AccountMetricCoordinator.ts: -------------------------------------------------------------------------------- 1 | import { DurableObject } from "cloudflare:workers"; 2 | import { 3 | ACCOUNT_LEVEL_QUERIES, 4 | getCloudflareMetricsClient, 5 | ZONE_LEVEL_QUERIES, 6 | } from "../cloudflare/client"; 7 | import { FREE_TIER_QUERIES } from "../cloudflare/queries"; 8 | import { 9 | filterZonesByIds, 10 | isFreeTierZone, 11 | parseCommaSeparated, 12 | } from "../lib/filters"; 13 | import { createLogger, type Logger } from "../lib/logger"; 14 | import type { MetricDefinition } from "../lib/metrics"; 15 | import { getConfig, type ResolvedConfig } from "../lib/runtime-config"; 16 | import { getTimeRange } from "../lib/time"; 17 | import type { Zone } from "../lib/types"; 18 | import { MetricExporter } from "./MetricExporter"; 19 | 20 | const STATE_KEY = "state"; 21 | 22 | // Account-scoped queries: all account-level + zone-batched (excludes zone-scoped REST queries) 23 | const ACCOUNT_SCOPED_QUERIES = [ 24 | ...ACCOUNT_LEVEL_QUERIES, 25 | ...ZONE_LEVEL_QUERIES.filter( 26 | (q) => q !== "ssl-certificates" && q !== "lb-weight-metrics", 27 | ), 28 | ] as const; 29 | 30 | // Zone-scoped REST queries (one DO per zone for parallelization and fault isolation) 31 | const ZONE_SCOPED_QUERIES = ["ssl-certificates", "lb-weight-metrics"] as const; 32 | 33 | type AccountMetricCoordinatorState = { 34 | accountId: string; 35 | accountName: string; 36 | zones: Zone[]; 37 | totalZoneCount: number; 38 | firewallRules: Record; 39 | lastZoneFetch: number; 40 | lastRefresh: number; 41 | }; 42 | 43 | /** 44 | * Coordinates metric collection for a Cloudflare account and manages zone list caching and distributes work to MetricExporter DOs. 45 | */ 46 | export class AccountMetricCoordinator extends DurableObject { 47 | private state: AccountMetricCoordinatorState | undefined; 48 | 49 | constructor(ctx: DurableObjectState, env: Env) { 50 | super(ctx, env); 51 | ctx.blockConcurrencyWhile(async () => { 52 | this.state = 53 | await ctx.storage.get(STATE_KEY); 54 | }); 55 | } 56 | 57 | /** 58 | * Creates logger instance with account-specific tag. 59 | * 60 | * @param config Resolved runtime configuration. 61 | * @returns Logger instance. 62 | */ 63 | private createLogger(config: ResolvedConfig): Logger { 64 | const state = this.getState(); 65 | const tag = state.accountName.toLowerCase().replace(/[ -]/g, "_"); 66 | return createLogger("account_coordinator", { 67 | format: config.logFormat, 68 | level: config.logLevel, 69 | }).child(tag); 70 | } 71 | 72 | /** 73 | * Gets current coordinator state. 74 | * 75 | * @returns Current state. 76 | * @throws {Error} When state not initialized. 77 | */ 78 | private getState(): AccountMetricCoordinatorState { 79 | if (this.state === undefined) { 80 | console.error( 81 | "[account_coordinator] State not initialized - initialize() must be called first", 82 | ); 83 | throw new Error("State not initialized"); 84 | } 85 | return this.state; 86 | } 87 | 88 | /** 89 | * Gets or creates coordinator stub for account and ensures coordinator is initialized before returning. 90 | * 91 | * @param accountId Cloudflare account ID. 92 | * @param accountName Account display name for logging. 93 | * @param env Worker environment bindings. 94 | * @returns Initialized coordinator stub. 95 | */ 96 | static async get(accountId: string, accountName: string, env: Env) { 97 | const stub = env.AccountMetricCoordinator.getByName(`account:${accountId}`); 98 | await stub.initialize(accountId, accountName); 99 | return stub; 100 | } 101 | 102 | /** 103 | * Initializes coordinator state and starts alarm cycle. Idempotent safe to call multiple times. 104 | * 105 | * @param accountId Cloudflare account ID. 106 | * @param accountName Account display name for logging. 107 | */ 108 | async initialize(accountId: string, accountName: string): Promise { 109 | if (this.state !== undefined) { 110 | return; 111 | } 112 | 113 | const config = await getConfig(this.env); 114 | 115 | this.state = { 116 | accountId, 117 | accountName, 118 | zones: [], 119 | totalZoneCount: 0, 120 | firewallRules: {}, 121 | lastZoneFetch: 0, 122 | lastRefresh: 0, 123 | }; 124 | 125 | await this.ctx.storage.put(STATE_KEY, this.state); 126 | await this.ctx.storage.setAlarm( 127 | Date.now() + config.metricRefreshIntervalSeconds * 1000, 128 | ); 129 | } 130 | 131 | override async alarm(): Promise { 132 | const config = await getConfig(this.env); 133 | const logger = this.createLogger(config); 134 | logger.info("Alarm fired, refreshing zones"); 135 | await this.refresh(config, logger); 136 | } 137 | 138 | /** 139 | * Refreshes zone list and pushes context to exporters. Exporters handle their own metric fetching via alarms. 140 | * 141 | * @param config Resolved runtime configuration. 142 | * @param logger Logger instance. 143 | */ 144 | private async refresh(config: ResolvedConfig, logger: Logger): Promise { 145 | logger.info("Starting refresh"); 146 | 147 | try { 148 | await this.refreshZonesAndPushContext(config, logger); 149 | 150 | this.state = { ...this.getState(), lastRefresh: Date.now() }; 151 | await this.ctx.storage.put(STATE_KEY, this.state); 152 | } catch (error) { 153 | const msg = error instanceof Error ? error.message : String(error); 154 | logger.error("Refresh failed", { error: msg }); 155 | } 156 | 157 | await this.ctx.storage.setAlarm( 158 | Date.now() + config.metricRefreshIntervalSeconds * 1000, 159 | ); 160 | } 161 | 162 | /** 163 | * Refreshes zone list if stale then pushes context to all exporters. 164 | * 165 | * @param config Resolved runtime configuration. 166 | * @param logger Logger instance. 167 | */ 168 | private async refreshZonesAndPushContext( 169 | config: ResolvedConfig, 170 | logger: Logger, 171 | ): Promise { 172 | const state = this.getState(); 173 | const ttlMs = config.zoneListCacheTtlSeconds * 1000; 174 | const isStale = Date.now() - state.lastZoneFetch >= ttlMs; 175 | 176 | // Calculate shared time range once for all exporters in this refresh cycle 177 | const timeRange = getTimeRange( 178 | config.scrapeDelaySeconds, 179 | config.timeWindowSeconds, 180 | ); 181 | 182 | let zones = state.zones; 183 | let firewallRules = state.firewallRules; 184 | 185 | if (isStale || zones.length === 0) { 186 | const client = getCloudflareMetricsClient(this.env); 187 | logger.info("Refreshing zones"); 188 | 189 | const allZones = await client.getZones(state.accountId); 190 | 191 | // Apply zone whitelist if set 192 | const cfZonesSet = 193 | config.cfZones !== null ? parseCommaSeparated(config.cfZones) : null; 194 | zones = 195 | cfZonesSet !== null ? filterZonesByIds(allZones, cfZonesSet) : allZones; 196 | 197 | // Build firewall rules map 198 | firewallRules = {}; 199 | const rulesResults = await Promise.all( 200 | zones.map((zone) => 201 | client.getFirewallRules(zone.id).catch((error) => { 202 | const msg = error instanceof Error ? error.message : String(error); 203 | logger.warn("Failed to fetch firewall rules", { 204 | zone: zone.name, 205 | error: msg, 206 | }); 207 | return new Map(); 208 | }), 209 | ), 210 | ); 211 | for (const rules of rulesResults) { 212 | for (const [id, name] of rules) { 213 | firewallRules[id] = name; 214 | } 215 | } 216 | 217 | this.state = { 218 | ...state, 219 | zones, 220 | totalZoneCount: allZones.length, 221 | firewallRules, 222 | lastZoneFetch: Date.now(), 223 | }; 224 | await this.ctx.storage.put(STATE_KEY, this.state); 225 | 226 | logger.info("Zones cached", { 227 | total: allZones.length, 228 | filtered: zones.length, 229 | }); 230 | } 231 | 232 | // Check if this account is marked as free tier 233 | const cfFreeTierSet = parseCommaSeparated(config.cfFreeTierAccounts); 234 | const isFreeTierAccount = cfFreeTierSet.has(state.accountId); 235 | 236 | // Filter queries based on account tier 237 | const accountQueries = isFreeTierAccount 238 | ? ACCOUNT_SCOPED_QUERIES.filter((q) => 239 | FREE_TIER_QUERIES.includes(q as (typeof FREE_TIER_QUERIES)[number]), 240 | ) 241 | : ACCOUNT_SCOPED_QUERIES; 242 | 243 | // Push zone context to account-scoped exporters AND initialize zone-scoped exporters concurrently 244 | await Promise.all([ 245 | // Account-scoped exporters 246 | ...accountQueries.map(async (query) => { 247 | try { 248 | const exporter = await MetricExporter.get( 249 | `account:${state.accountId}:${query}`, 250 | this.env, 251 | ); 252 | await exporter.updateZoneContext( 253 | state.accountId, 254 | state.accountName, 255 | zones, 256 | firewallRules, 257 | timeRange, 258 | ); 259 | } catch (error) { 260 | const msg = error instanceof Error ? error.message : String(error); 261 | logger.error("Failed to update zone context", { 262 | query, 263 | error: msg, 264 | }); 265 | } 266 | }), 267 | // Zone-scoped exporters (skip for free tier accounts) 268 | ...(isFreeTierAccount 269 | ? [] 270 | : zones.flatMap((zone) => 271 | ZONE_SCOPED_QUERIES.map(async (query) => { 272 | try { 273 | const exporter = await MetricExporter.get( 274 | `zone:${zone.id}:${query}`, 275 | this.env, 276 | ); 277 | await exporter.initializeZone( 278 | zone, 279 | state.accountId, 280 | state.accountName, 281 | timeRange, 282 | ); 283 | } catch (error) { 284 | const msg = 285 | error instanceof Error ? error.message : String(error); 286 | logger.error("Failed to initialize zone exporter", { 287 | zone: zone.name, 288 | query, 289 | error: msg, 290 | }); 291 | } 292 | }), 293 | )), 294 | ]); 295 | 296 | logger.info("Context pushed to exporters", { 297 | account_scoped: accountQueries.length, 298 | zone_scoped: isFreeTierAccount 299 | ? 0 300 | : zones.length * ZONE_SCOPED_QUERIES.length, 301 | }); 302 | } 303 | 304 | /** 305 | * Collects and aggregates metrics from all MetricExporter DOs. 306 | * 307 | * @returns Metrics and zone counts. 308 | */ 309 | async export(): Promise<{ 310 | metrics: MetricDefinition[]; 311 | zoneCounts: { 312 | total: number; 313 | filtered: number; 314 | processed: number; 315 | skippedFreeTier: number; 316 | }; 317 | }> { 318 | const config = await getConfig(this.env); 319 | const logger = this.createLogger(config); 320 | 321 | logger.info("Exporting metrics"); 322 | 323 | // Ensure exporters have been initialized 324 | const staleThreshold = config.metricRefreshIntervalSeconds * 2 * 1000; 325 | const initialState = this.getState(); 326 | if ( 327 | initialState.lastRefresh === 0 || 328 | Date.now() - initialState.lastRefresh > staleThreshold 329 | ) { 330 | await this.refresh(config, logger); 331 | } 332 | 333 | // Re-get state after potential refresh (this.state may have been updated) 334 | const state = this.getState(); 335 | 336 | // Check if this account is marked as free tier 337 | const cfFreeTierSet = parseCommaSeparated(config.cfFreeTierAccounts); 338 | const isFreeTierAccount = cfFreeTierSet.has(state.accountId); 339 | 340 | // Filter queries based on account tier 341 | const accountQueries = isFreeTierAccount 342 | ? ACCOUNT_SCOPED_QUERIES.filter((q) => 343 | FREE_TIER_QUERIES.includes(q as (typeof FREE_TIER_QUERIES)[number]), 344 | ) 345 | : ACCOUNT_SCOPED_QUERIES; 346 | 347 | // Collect from account-scoped exporters 348 | const accountMetricsResults = await Promise.all( 349 | accountQueries.map(async (query) => { 350 | try { 351 | const exporter = await MetricExporter.get( 352 | `account:${state.accountId}:${query}`, 353 | this.env, 354 | ); 355 | return await exporter.export(); 356 | } catch (error) { 357 | const msg = error instanceof Error ? error.message : String(error); 358 | logger.error("Failed to export account metrics", { 359 | query, 360 | error: msg, 361 | }); 362 | return []; 363 | } 364 | }), 365 | ); 366 | 367 | // Collect from zone-scoped exporters (skip for free tier accounts) 368 | const zoneMetricsResults = isFreeTierAccount 369 | ? [] 370 | : await Promise.all( 371 | state.zones.flatMap((zone) => 372 | ZONE_SCOPED_QUERIES.map(async (query) => { 373 | try { 374 | const exporter = await MetricExporter.get( 375 | `zone:${zone.id}:${query}`, 376 | this.env, 377 | ); 378 | return await exporter.export(); 379 | } catch (error) { 380 | const msg = 381 | error instanceof Error ? error.message : String(error); 382 | logger.error("Failed to export zone metrics", { 383 | zone: zone.name, 384 | query, 385 | error: msg, 386 | }); 387 | return []; 388 | } 389 | }), 390 | ), 391 | ); 392 | 393 | const allMetrics = [...accountMetricsResults, ...zoneMetricsResults].flat(); 394 | 395 | // Count unique zones with metrics from all results 396 | const zonesWithMetrics = new Set(); 397 | for (const metric of allMetrics) { 398 | for (const v of metric.values) { 399 | const zone = v.labels.zone; 400 | if (zone) { 401 | zonesWithMetrics.add(zone); 402 | } 403 | } 404 | } 405 | const processedZones = zonesWithMetrics.size; 406 | 407 | // Count free tier zones 408 | const freeTierCount = state.zones.filter(isFreeTierZone).length; 409 | 410 | return { 411 | metrics: allMetrics, 412 | zoneCounts: { 413 | total: state.totalZoneCount, 414 | filtered: state.zones.length, 415 | processed: processedZones, 416 | skippedFreeTier: freeTierCount, 417 | }, 418 | }; 419 | } 420 | } 421 | -------------------------------------------------------------------------------- /src/components/LandingPageScript.tsx: -------------------------------------------------------------------------------- 1 | import { html } from "hono/html"; 2 | import type { FC } from "hono/jsx"; 3 | 4 | type Props = { metricsPath: string; disableConfigApi: boolean }; 5 | 6 | export const LandingPageScript: FC = ({ metricsPath, disableConfigApi }) => { 7 | return html` 8 | 352 | `; 353 | }; 354 | -------------------------------------------------------------------------------- /src/cloudflare/gql/queries.ts: -------------------------------------------------------------------------------- 1 | import { graphql } from "./client"; 2 | 3 | export const HTTPMetricsQuery = graphql(` 4 | query HTTPMetrics( 5 | $zoneIDs: [string!] 6 | $mintime: Time! 7 | $maxtime: Time! 8 | $limit: uint64! 9 | ) { 10 | viewer { 11 | zones(filter: { zoneTag_in: $zoneIDs }) { 12 | zoneTag 13 | httpRequests1mGroups( 14 | limit: $limit 15 | filter: { datetime_geq: $mintime, datetime_lt: $maxtime } 16 | ) { 17 | uniq { 18 | uniques 19 | } 20 | sum { 21 | browserMap { 22 | pageViews 23 | uaBrowserFamily 24 | } 25 | bytes 26 | cachedBytes 27 | cachedRequests 28 | contentTypeMap { 29 | bytes 30 | requests 31 | edgeResponseContentTypeName 32 | } 33 | countryMap { 34 | bytes 35 | clientCountryName 36 | requests 37 | threats 38 | } 39 | encryptedBytes 40 | encryptedRequests 41 | pageViews 42 | requests 43 | responseStatusMap { 44 | edgeResponseStatus 45 | requests 46 | } 47 | threatPathingMap { 48 | requests 49 | threatPathingName 50 | } 51 | threats 52 | clientHTTPVersionMap { 53 | clientHTTPProtocol 54 | requests 55 | } 56 | clientSSLMap { 57 | clientSSLProtocol 58 | requests 59 | } 60 | ipClassMap { 61 | ipType 62 | requests 63 | } 64 | } 65 | dimensions { 66 | datetime 67 | } 68 | } 69 | firewallEventsAdaptiveGroups( 70 | limit: $limit 71 | filter: { datetime_geq: $mintime, datetime_lt: $maxtime } 72 | ) { 73 | count 74 | dimensions { 75 | action 76 | source 77 | ruleId 78 | clientRequestHTTPHost 79 | clientCountryName 80 | botScore 81 | botScoreSrcName 82 | } 83 | } 84 | } 85 | } 86 | } 87 | `); 88 | 89 | export const HTTPMetricsQueryNoBots = graphql(` 90 | query HTTPMetricsNoBots( 91 | $zoneIDs: [string!] 92 | $mintime: Time! 93 | $maxtime: Time! 94 | $limit: uint64! 95 | ) { 96 | viewer { 97 | zones(filter: { zoneTag_in: $zoneIDs }) { 98 | zoneTag 99 | httpRequests1mGroups( 100 | limit: $limit 101 | filter: { datetime_geq: $mintime, datetime_lt: $maxtime } 102 | ) { 103 | uniq { 104 | uniques 105 | } 106 | sum { 107 | browserMap { 108 | pageViews 109 | uaBrowserFamily 110 | } 111 | bytes 112 | cachedBytes 113 | cachedRequests 114 | contentTypeMap { 115 | bytes 116 | requests 117 | edgeResponseContentTypeName 118 | } 119 | countryMap { 120 | bytes 121 | clientCountryName 122 | requests 123 | threats 124 | } 125 | encryptedBytes 126 | encryptedRequests 127 | pageViews 128 | requests 129 | responseStatusMap { 130 | edgeResponseStatus 131 | requests 132 | } 133 | threatPathingMap { 134 | requests 135 | threatPathingName 136 | } 137 | threats 138 | clientHTTPVersionMap { 139 | clientHTTPProtocol 140 | requests 141 | } 142 | clientSSLMap { 143 | clientSSLProtocol 144 | requests 145 | } 146 | ipClassMap { 147 | ipType 148 | requests 149 | } 150 | } 151 | dimensions { 152 | datetime 153 | } 154 | } 155 | firewallEventsAdaptiveGroups( 156 | limit: $limit 157 | filter: { datetime_geq: $mintime, datetime_lt: $maxtime } 158 | ) { 159 | count 160 | dimensions { 161 | action 162 | source 163 | ruleId 164 | clientRequestHTTPHost 165 | clientCountryName 166 | } 167 | } 168 | } 169 | } 170 | } 171 | `); 172 | 173 | export const FirewallMetricsQuery = graphql(` 174 | query FirewallMetrics( 175 | $zoneIDs: [string!] 176 | $mintime: Time! 177 | $maxtime: Time! 178 | $limit: uint64! 179 | ) { 180 | viewer { 181 | zones(filter: { zoneTag_in: $zoneIDs }) { 182 | zoneTag 183 | firewallEventsAdaptiveGroups( 184 | limit: $limit 185 | filter: { datetime_geq: $mintime, datetime_lt: $maxtime } 186 | ) { 187 | count 188 | dimensions { 189 | action 190 | source 191 | ruleId 192 | clientRequestHTTPHost 193 | clientCountryName 194 | } 195 | } 196 | } 197 | } 198 | } 199 | `); 200 | 201 | export const HealthCheckMetricsQuery = graphql(` 202 | query HealthCheckMetrics( 203 | $zoneIDs: [string!] 204 | $mintime: Time! 205 | $maxtime: Time! 206 | $limit: uint64! 207 | ) { 208 | viewer { 209 | zones(filter: { zoneTag_in: $zoneIDs }) { 210 | zoneTag 211 | healthCheckEventsAdaptiveGroups( 212 | limit: $limit 213 | filter: { datetime_geq: $mintime, datetime_lt: $maxtime } 214 | ) { 215 | count 216 | avg { 217 | rttMs 218 | timeToFirstByteMs 219 | tcpConnMs 220 | tlsHandshakeMs 221 | } 222 | dimensions { 223 | healthStatus 224 | originIP 225 | region 226 | fqdn 227 | failureReason 228 | } 229 | } 230 | } 231 | } 232 | } 233 | `); 234 | 235 | export const AdaptiveMetricsQuery = graphql(` 236 | query AdaptiveMetrics( 237 | $zoneIDs: [string!] 238 | $mintime: Time! 239 | $maxtime: Time! 240 | $limit: uint64! 241 | ) { 242 | viewer { 243 | zones(filter: { zoneTag_in: $zoneIDs }) { 244 | zoneTag 245 | httpRequestsAdaptiveGroups( 246 | limit: $limit 247 | filter: { 248 | datetime_geq: $mintime 249 | datetime_lt: $maxtime 250 | cacheStatus_notin: ["hit"] 251 | originResponseStatus_in: [ 252 | 400 253 | 404 254 | 500 255 | 502 256 | 503 257 | 504 258 | 522 259 | 523 260 | 524 261 | ] 262 | } 263 | ) { 264 | count 265 | dimensions { 266 | originResponseStatus 267 | clientCountryName 268 | clientRequestHTTPHost 269 | } 270 | avg { 271 | originResponseDurationMs 272 | } 273 | } 274 | } 275 | } 276 | } 277 | `); 278 | 279 | export const EdgeCountryMetricsQuery = graphql(` 280 | query EdgeCountryMetrics( 281 | $zoneIDs: [string!] 282 | $mintime: Time! 283 | $maxtime: Time! 284 | $limit: uint64! 285 | ) { 286 | viewer { 287 | zones(filter: { zoneTag_in: $zoneIDs }) { 288 | zoneTag 289 | httpRequestsEdgeCountryHost: httpRequestsAdaptiveGroups( 290 | limit: $limit 291 | filter: { datetime_geq: $mintime, datetime_lt: $maxtime } 292 | ) { 293 | count 294 | dimensions { 295 | edgeResponseStatus 296 | clientCountryName 297 | clientRequestHTTPHost 298 | } 299 | } 300 | } 301 | } 302 | } 303 | `); 304 | 305 | export const ColoMetricsQuery = graphql(` 306 | query ColoMetrics( 307 | $zoneIDs: [string!] 308 | $mintime: Time! 309 | $maxtime: Time! 310 | $limit: uint64! 311 | ) { 312 | viewer { 313 | zones(filter: { zoneTag_in: $zoneIDs }) { 314 | zoneTag 315 | httpRequestsAdaptiveGroups( 316 | limit: $limit 317 | filter: { datetime_geq: $mintime, datetime_lt: $maxtime } 318 | ) { 319 | count 320 | avg { 321 | sampleInterval 322 | } 323 | dimensions { 324 | clientRequestHTTPHost 325 | coloCode 326 | datetime 327 | originResponseStatus 328 | } 329 | sum { 330 | edgeResponseBytes 331 | visits 332 | } 333 | } 334 | } 335 | } 336 | } 337 | `); 338 | 339 | export const ColoErrorMetricsQuery = graphql(` 340 | query ColoErrorMetrics( 341 | $zoneIDs: [string!] 342 | $mintime: Time! 343 | $maxtime: Time! 344 | $limit: uint64! 345 | ) { 346 | viewer { 347 | zones(filter: { zoneTag_in: $zoneIDs }) { 348 | zoneTag 349 | httpRequestsAdaptiveGroups( 350 | limit: $limit 351 | filter: { 352 | datetime_geq: $mintime 353 | datetime_lt: $maxtime 354 | edgeResponseStatus_geq: 400 355 | } 356 | ) { 357 | count 358 | dimensions { 359 | clientRequestHTTPHost 360 | coloCode 361 | edgeResponseStatus 362 | } 363 | sum { 364 | edgeResponseBytes 365 | visits 366 | } 367 | } 368 | } 369 | } 370 | } 371 | `); 372 | 373 | export const WorkerTotalsQuery = graphql(` 374 | query WorkerTotals( 375 | $accountID: string! 376 | $mintime: Time! 377 | $maxtime: Time! 378 | $limit: uint64! 379 | ) { 380 | viewer { 381 | accounts(filter: { accountTag: $accountID }) { 382 | workersInvocationsAdaptive( 383 | limit: $limit 384 | filter: { datetime_geq: $mintime, datetime_lt: $maxtime } 385 | ) { 386 | dimensions { 387 | scriptName 388 | status 389 | } 390 | sum { 391 | requests 392 | errors 393 | duration 394 | } 395 | quantiles { 396 | cpuTimeP50 397 | cpuTimeP75 398 | cpuTimeP99 399 | cpuTimeP999 400 | durationP50 401 | durationP75 402 | durationP99 403 | durationP999 404 | } 405 | } 406 | } 407 | } 408 | } 409 | `); 410 | 411 | // Note: Cloudflare's accounts filter only supports single accountTag, not accountTag_in 412 | // Use WorkerTotalsQuery for individual account queries 413 | 414 | export const LoadBalancerMetricsQuery = graphql(` 415 | query LoadBalancerMetrics( 416 | $zoneIDs: [string!] 417 | $mintime: Time! 418 | $maxtime: Time! 419 | $limit: uint64! 420 | ) { 421 | viewer { 422 | zones(filter: { zoneTag_in: $zoneIDs }) { 423 | zoneTag 424 | loadBalancingRequestsAdaptiveGroups( 425 | filter: { datetime_geq: $mintime, datetime_lt: $maxtime } 426 | limit: $limit 427 | ) { 428 | count 429 | dimensions { 430 | lbName 431 | selectedPoolName 432 | selectedOriginName 433 | region 434 | proxied 435 | selectedPoolAvgRttMs 436 | selectedPoolHealthy 437 | steeringPolicy 438 | numberOriginsSelected 439 | } 440 | } 441 | loadBalancingRequestsAdaptive( 442 | filter: { datetime_geq: $mintime, datetime_lt: $maxtime } 443 | limit: $limit 444 | ) { 445 | lbName 446 | pools { 447 | id 448 | poolName 449 | healthy 450 | healthCheckEnabled 451 | avgRttMs 452 | } 453 | } 454 | } 455 | } 456 | } 457 | `); 458 | 459 | export const LogpushAccountMetricsQuery = graphql(` 460 | query LogpushAccountMetrics( 461 | $accountID: string! 462 | $limit: uint64! 463 | $mintime: Time! 464 | $maxtime: Time! 465 | ) { 466 | viewer { 467 | accounts(filter: { accountTag: $accountID }) { 468 | logpushHealthAdaptiveGroups( 469 | filter: { 470 | datetime_geq: $mintime 471 | datetime_lt: $maxtime 472 | status_neq: 200 473 | } 474 | limit: $limit 475 | ) { 476 | count 477 | dimensions { 478 | jobId 479 | status 480 | destinationType 481 | datetime 482 | final 483 | } 484 | } 485 | } 486 | } 487 | } 488 | `); 489 | 490 | // Note: Cloudflare's accounts filter only supports single accountTag, not accountTag_in 491 | // Use LogpushAccountMetricsQuery for individual account queries 492 | 493 | export const LogpushZoneMetricsQuery = graphql(` 494 | query LogpushZoneMetrics( 495 | $zoneIDs: [string!] 496 | $limit: uint64! 497 | $mintime: Time! 498 | $maxtime: Time! 499 | ) { 500 | viewer { 501 | zones(filter: { zoneTag_in: $zoneIDs }) { 502 | zoneTag 503 | logpushHealthAdaptiveGroups( 504 | filter: { 505 | datetime_geq: $mintime 506 | datetime_lt: $maxtime 507 | status_neq: 200 508 | } 509 | limit: $limit 510 | ) { 511 | count 512 | dimensions { 513 | jobId 514 | status 515 | destinationType 516 | datetime 517 | final 518 | } 519 | } 520 | } 521 | } 522 | } 523 | `); 524 | 525 | export const MagicTransitMetricsQuery = graphql(` 526 | query MagicTransitMetrics( 527 | $accountID: string! 528 | $limit: uint64! 529 | $mintime: Time! 530 | $maxtime: Time! 531 | ) { 532 | viewer { 533 | accounts(filter: { accountTag: $accountID }) { 534 | magicTransitTunnelHealthChecksAdaptiveGroups( 535 | limit: $limit 536 | filter: { datetime_geq: $mintime, datetime_lt: $maxtime } 537 | ) { 538 | count 539 | dimensions { 540 | active 541 | datetime 542 | edgeColoCity 543 | edgeColoCountry 544 | edgePopName 545 | remoteTunnelIPv4 546 | resultStatus 547 | siteName 548 | tunnelName 549 | } 550 | } 551 | } 552 | } 553 | } 554 | `); 555 | 556 | // Note: Cloudflare's accounts filter only supports single accountTag, not accountTag_in 557 | // Use MagicTransitMetricsQuery for individual account queries 558 | 559 | export const RequestMethodMetricsQuery = graphql(` 560 | query RequestMethodMetrics( 561 | $zoneIDs: [string!] 562 | $mintime: Time! 563 | $maxtime: Time! 564 | $limit: uint64! 565 | ) { 566 | viewer { 567 | zones(filter: { zoneTag_in: $zoneIDs }) { 568 | zoneTag 569 | httpRequestsAdaptiveGroups( 570 | limit: $limit 571 | filter: { datetime_geq: $mintime, datetime_lt: $maxtime } 572 | ) { 573 | count 574 | dimensions { 575 | clientRequestHTTPMethodName 576 | } 577 | } 578 | } 579 | } 580 | } 581 | `); 582 | 583 | export const OriginStatusMetricsQuery = graphql(` 584 | query OriginStatusMetrics( 585 | $zoneIDs: [string!] 586 | $mintime: Time! 587 | $maxtime: Time! 588 | $limit: uint64! 589 | ) { 590 | viewer { 591 | zones(filter: { zoneTag_in: $zoneIDs }) { 592 | zoneTag 593 | httpRequestsAdaptiveGroups( 594 | limit: $limit 595 | filter: { datetime_geq: $mintime, datetime_lt: $maxtime } 596 | ) { 597 | count 598 | dimensions { 599 | originResponseStatus 600 | clientCountryName 601 | clientRequestHTTPHost 602 | } 603 | } 604 | } 605 | } 606 | } 607 | `); 608 | 609 | export const CacheMissMetricsQuery = graphql(` 610 | query CacheMissMetrics( 611 | $zoneIDs: [string!] 612 | $mintime: Time! 613 | $maxtime: Time! 614 | $limit: uint64! 615 | ) { 616 | viewer { 617 | zones(filter: { zoneTag_in: $zoneIDs }) { 618 | zoneTag 619 | httpRequestsAdaptiveGroups( 620 | filter: { 621 | datetime_geq: $mintime 622 | datetime_lt: $maxtime 623 | cacheStatus: "miss" 624 | } 625 | limit: $limit 626 | ) { 627 | count 628 | avg { 629 | originResponseDurationMs 630 | } 631 | dimensions { 632 | clientCountryName 633 | clientRequestHTTPHost 634 | } 635 | } 636 | } 637 | } 638 | } 639 | `); 640 | -------------------------------------------------------------------------------- /src/durable-objects/MetricExporter.ts: -------------------------------------------------------------------------------- 1 | import { DurableObject } from "cloudflare:workers"; 2 | import { 3 | getCloudflareMetricsClient, 4 | isAccountLevelQuery, 5 | isZoneLevelQuery, 6 | } from "../cloudflare/client"; 7 | import { isPaidTierGraphQLQuery } from "../cloudflare/queries"; 8 | import { partitionZonesByTier } from "../lib/filters"; 9 | import { createLogger, type Logger } from "../lib/logger"; 10 | import type { MetricDefinition, MetricValue } from "../lib/metrics"; 11 | import { getConfig, type ResolvedConfig } from "../lib/runtime-config"; 12 | import { getTimeRange, metricKey } from "../lib/time"; 13 | import { 14 | type CounterState, 15 | MetricExporterIdSchema, 16 | type MetricExporterIdString, 17 | type TimeRange, 18 | type Zone, 19 | } from "../lib/types"; 20 | 21 | const STATE_KEY = "state"; 22 | 23 | type MetricExporterState = { 24 | // Core identity 25 | scopeType: "account" | "zone"; 26 | scopeId: string; 27 | queryName: string; 28 | 29 | // Metric storage 30 | counters: Record; 31 | metrics: MetricDefinition[]; 32 | lastIngest: number; 33 | 34 | // Context for fetching (account-scoped) 35 | accountId: string; 36 | accountName: string; 37 | zones: Zone[]; 38 | firewallRules: Record; 39 | 40 | // Context for fetching (zone-scoped) 41 | zoneMetadata: Zone | null; 42 | 43 | // Refresh state 44 | refreshInterval: number; 45 | lastRefresh: number; 46 | lastError: string | null; 47 | 48 | // SSL cert cache (zone-scoped only) 49 | lastSslFetch: number; 50 | }; 51 | 52 | /** 53 | * Durable Object that fetches and exports Prometheus metrics for a specific query scope. 54 | * Handles counter accumulation, alarm-based refresh scheduling, and metric caching. 55 | */ 56 | export class MetricExporter extends DurableObject { 57 | private state: MetricExporterState | undefined; 58 | 59 | constructor(ctx: DurableObjectState, env: Env) { 60 | super(ctx, env); 61 | ctx.blockConcurrencyWhile(async () => { 62 | this.state = await ctx.storage.get(STATE_KEY); 63 | }); 64 | } 65 | 66 | /** 67 | * Create a logger instance with context from the exporter's state. 68 | * 69 | * @param config Resolved runtime configuration. 70 | * @returns Logger instance with scope type, scope ID, and query name context. 71 | */ 72 | private createLogger(config: ResolvedConfig): Logger { 73 | const state = this.getState(); 74 | return createLogger("metric_exporter", { 75 | format: config.logFormat, 76 | level: config.logLevel, 77 | }) 78 | .child(state.scopeType) 79 | .child(state.scopeId) 80 | .child(state.queryName); 81 | } 82 | 83 | /** 84 | * Get the current state or throw if not initialized. 85 | * 86 | * @returns Current state. 87 | * @throws {Error} When state is undefined. 88 | */ 89 | private getState(): MetricExporterState { 90 | if (this.state === undefined) { 91 | console.error( 92 | "State not initialized - initialize() must be called first", 93 | ); 94 | throw new Error("State not initialized"); 95 | } 96 | return this.state; 97 | } 98 | 99 | /** 100 | * Get or create a MetricExporter instance by ID, ensuring it's initialized. 101 | * 102 | * @param id Composite ID in format "scopeType:scopeId:queryName". 103 | * @param env Worker environment bindings. 104 | * @returns Initialized MetricExporter stub. 105 | */ 106 | static async get(id: MetricExporterIdString, env: Env) { 107 | const stub = env.MetricExporter.getByName(id); 108 | await stub.initialize(id); 109 | return stub; 110 | } 111 | 112 | /** 113 | * Initialize the exporter state from a composite ID. 114 | * Idempotent - skips if already initialized. 115 | * 116 | * @param id Composite ID string to parse into scope type, scope ID, and query name. 117 | * @throws {ZodError} When ID format is invalid. 118 | */ 119 | async initialize(id: string): Promise { 120 | if (this.state !== undefined) { 121 | return; 122 | } 123 | 124 | const config = await getConfig(this.env); 125 | const parsed = MetricExporterIdSchema.parse(id); 126 | 127 | this.state = { 128 | scopeType: parsed.scopeType, 129 | scopeId: parsed.scopeId, 130 | queryName: parsed.queryName, 131 | counters: {}, 132 | metrics: [], 133 | lastIngest: 0, 134 | accountId: "", 135 | accountName: "", 136 | zones: [], 137 | firewallRules: {}, 138 | zoneMetadata: null, 139 | refreshInterval: config.metricRefreshIntervalSeconds, 140 | lastRefresh: 0, 141 | lastError: null, 142 | lastSslFetch: 0, 143 | }; 144 | 145 | await this.ctx.storage.put(STATE_KEY, this.state); 146 | } 147 | 148 | /** 149 | * Update zone context for account-scoped exporters. 150 | * Called by AccountMetricCoordinator after zone list refresh. 151 | * Triggers immediate fetch on first context push. 152 | * 153 | * @param accountId Cloudflare account ID. 154 | * @param accountName Account display name. 155 | * @param zones List of zones in the account. 156 | * @param firewallRules Map of firewall rule IDs to descriptions. 157 | * @param timeRange Shared time range for metrics queries. 158 | */ 159 | async updateZoneContext( 160 | accountId: string, 161 | accountName: string, 162 | zones: Zone[], 163 | firewallRules: Record, 164 | timeRange: TimeRange, 165 | ): Promise { 166 | const config = await getConfig(this.env); 167 | const logger = this.createLogger(config); 168 | const state = this.getState(); 169 | 170 | if (state.scopeType !== "account") { 171 | logger.warn("updateZoneContext called on non-account exporter"); 172 | return; 173 | } 174 | 175 | const isFirstContext = 176 | state.zones.length === 0 && zones.length > 0 && state.lastRefresh === 0; 177 | 178 | this.state = { 179 | ...state, 180 | accountId, 181 | accountName, 182 | zones, 183 | firewallRules, 184 | }; 185 | await this.ctx.storage.put(STATE_KEY, this.state); 186 | 187 | logger.info("Zone context updated", { zone_count: zones.length }); 188 | 189 | // On first context push, fetch immediately then schedule recurring alarm 190 | if (isFirstContext) { 191 | await this.refreshWithTimeRange(timeRange, config, logger); 192 | } 193 | } 194 | 195 | /** 196 | * Initialize zone-scoped exporter with zone metadata. 197 | * Called by AccountMetricCoordinator when ensuring zone exporters exist. 198 | * Triggers immediate fetch on first initialization. 199 | * 200 | * @param zone Zone metadata including ID, name, and plan. 201 | * @param accountId Cloudflare account ID that owns the zone. 202 | * @param accountName Account display name. 203 | * @param timeRange Shared time range for metrics queries. 204 | */ 205 | async initializeZone( 206 | zone: Zone, 207 | accountId: string, 208 | accountName: string, 209 | timeRange: TimeRange, 210 | ): Promise { 211 | const config = await getConfig(this.env); 212 | const logger = this.createLogger(config); 213 | const state = this.getState(); 214 | 215 | if (state.scopeType !== "zone") { 216 | logger.warn("initializeZone called on non-zone exporter"); 217 | return; 218 | } 219 | 220 | const isFirstInit = state.zoneMetadata === null && state.lastRefresh === 0; 221 | 222 | this.state = { 223 | ...state, 224 | accountId, 225 | accountName, 226 | zoneMetadata: zone, 227 | }; 228 | await this.ctx.storage.put(STATE_KEY, this.state); 229 | 230 | logger.info("Zone metadata set", { zone: zone.name }); 231 | 232 | // On first init, fetch immediately then schedule recurring alarm 233 | if (isFirstInit) { 234 | await this.refreshWithTimeRange(timeRange, config, logger); 235 | } 236 | } 237 | 238 | /** 239 | * Durable Object alarm handler. 240 | * Triggers metric refresh and reschedules next alarm with jitter. 241 | */ 242 | override async alarm(): Promise { 243 | const config = await getConfig(this.env); 244 | const logger = this.createLogger(config); 245 | logger.info("Alarm fired, refreshing"); 246 | const timeRange = getTimeRange( 247 | config.scrapeDelaySeconds, 248 | config.timeWindowSeconds, 249 | ); 250 | await this.refreshWithTimeRange(timeRange, config, logger); 251 | } 252 | 253 | /** 254 | * Public method for coordinator to trigger refresh with shared time range. 255 | * Called by AccountMetricCoordinator to ensure all exporters use the same time window. 256 | * 257 | * @param timeRange Shared time range calculated by coordinator. 258 | */ 259 | async triggerRefresh(timeRange: TimeRange): Promise { 260 | const config = await getConfig(this.env); 261 | const logger = this.createLogger(config); 262 | await this.refreshWithTimeRange(timeRange, config, logger); 263 | } 264 | 265 | /** 266 | * Refresh metrics from Cloudflare API using the provided time range. 267 | * Handles account-scoped and zone-scoped queries, processes counters, and schedules next alarm. 268 | * 269 | * @param timeRange Time range for metrics queries. 270 | * @param config Resolved runtime configuration. 271 | * @param logger Logger instance for logging. 272 | */ 273 | private async refreshWithTimeRange( 274 | timeRange: TimeRange, 275 | config: ResolvedConfig, 276 | logger: Logger, 277 | ): Promise { 278 | const state = this.getState(); 279 | 280 | // Skip if zone context not yet pushed (account-scoped needs zones) 281 | if (state.scopeType === "account" && state.zones.length === 0) { 282 | logger.info("Skipping refresh - no zone context yet"); 283 | await this.scheduleNextAlarm(config); 284 | return; 285 | } 286 | 287 | // Skip if zone metadata not set (zone-scoped) 288 | if (state.scopeType === "zone" && state.zoneMetadata === null) { 289 | logger.info("Skipping refresh - no zone metadata yet"); 290 | await this.scheduleNextAlarm(config); 291 | return; 292 | } 293 | 294 | // For zone-scoped (SSL certs), check cache TTL 295 | if (state.scopeType === "zone") { 296 | const cacheAgeMs = Date.now() - state.lastSslFetch; 297 | const cacheTtlMs = config.sslCertsCacheTtlSeconds * 1000; 298 | if (state.lastSslFetch > 0 && cacheAgeMs < cacheTtlMs) { 299 | logger.debug("SSL cert cache fresh, skipping fetch", { 300 | age_seconds: Math.floor(cacheAgeMs / 1000), 301 | ttl_seconds: config.sslCertsCacheTtlSeconds, 302 | }); 303 | await this.scheduleNextAlarm(config); 304 | return; 305 | } 306 | } 307 | 308 | const client = getCloudflareMetricsClient(this.env); 309 | 310 | try { 311 | let metrics: MetricDefinition[]; 312 | 313 | if (state.scopeType === "account") { 314 | metrics = await this.fetchAccountScopedMetrics( 315 | client, 316 | state, 317 | timeRange, 318 | logger, 319 | ); 320 | } else { 321 | metrics = await this.fetchZoneScopedMetrics(client, state); 322 | } 323 | 324 | const processed = this.processCounters(metrics, state.counters); 325 | 326 | this.state = { 327 | ...state, 328 | metrics: processed.metrics, 329 | counters: processed.counters, 330 | lastRefresh: Date.now(), 331 | lastSslFetch: 332 | state.scopeType === "zone" ? Date.now() : state.lastSslFetch, 333 | lastError: null, 334 | }; 335 | await this.ctx.storage.put(STATE_KEY, this.state); 336 | 337 | logger.info("Refresh complete", { 338 | metric_count: metrics.length, 339 | }); 340 | } catch (error) { 341 | const msg = error instanceof Error ? error.message : String(error); 342 | logger.error("Refresh failed", { error: msg }); 343 | this.state = { ...state, lastError: msg }; 344 | await this.ctx.storage.put(STATE_KEY, this.state); 345 | } 346 | 347 | await this.scheduleNextAlarm(config); 348 | } 349 | 350 | /** 351 | * Schedule the next alarm with jitter for time range alignment. 352 | * 353 | * @param config Resolved runtime configuration. 354 | */ 355 | private async scheduleNextAlarm(config: ResolvedConfig): Promise { 356 | const intervalMs = config.metricRefreshIntervalSeconds * 1000; 357 | 358 | // Get the start of the current minute interval 359 | const now = Date.now(); 360 | const startOfInterval = Math.floor(now / intervalMs) * intervalMs; 361 | 362 | // Add the jitter (1-5s) to the NEXT interval start 363 | // This ensures we always fire at ":01-05" of every interval 364 | const jitter = 1000 + Math.random() * 4000; 365 | const nextAlarm = startOfInterval + intervalMs + jitter; 366 | 367 | await this.ctx.storage.setAlarm(nextAlarm); 368 | } 369 | 370 | /** 371 | * Fetch account-scoped metrics from Cloudflare API. 372 | * Handles both account-level and zone-batched queries. 373 | * 374 | * @param client Cloudflare metrics client. 375 | * @param state Current exporter state. 376 | * @param timeRange Time range for metrics queries. 377 | * @param logger Logger instance. 378 | * @returns Array of metric definitions. 379 | */ 380 | private async fetchAccountScopedMetrics( 381 | client: ReturnType, 382 | state: MetricExporterState, 383 | timeRange: TimeRange, 384 | logger: Logger, 385 | ): Promise { 386 | const { queryName, accountId, accountName, zones, firewallRules } = state; 387 | 388 | // Account-level queries (worker-totals, logpush-account, magic-transit) 389 | if (isAccountLevelQuery(queryName)) { 390 | return client.getAccountMetrics( 391 | queryName, 392 | accountId, 393 | accountName, 394 | timeRange, 395 | ); 396 | } 397 | 398 | // Zone-batched queries - fetch all zones in one GraphQL call 399 | if (isZoneLevelQuery(queryName)) { 400 | // Filter out free tier zones for paid-tier GraphQL queries 401 | let zonesToQuery = zones; 402 | if (isPaidTierGraphQLQuery(queryName)) { 403 | const { paid, free } = partitionZonesByTier(zones); 404 | 405 | if (free.length > 0) { 406 | logger.info("Skipping free tier zones for paid-tier query", { 407 | skipped_zones: free.map((z) => z.name), 408 | processing_zones: paid.length, 409 | }); 410 | } 411 | 412 | zonesToQuery = paid; 413 | 414 | if (zonesToQuery.length === 0) { 415 | logger.info("No paid tier zones to query"); 416 | return []; 417 | } 418 | } 419 | 420 | const zoneIds = zonesToQuery.map((z) => z.id); 421 | return client.getZoneMetrics( 422 | queryName, 423 | zoneIds, 424 | zonesToQuery, 425 | firewallRules, 426 | timeRange, 427 | ); 428 | } 429 | 430 | // Unknown query - should not happen if IDs are constructed correctly 431 | console.error("Unknown query type", { queryName }); 432 | return []; 433 | } 434 | 435 | /** 436 | * Fetch zone-scoped metrics from Cloudflare API. 437 | * Handles SSL certificates and load balancer weight metrics. 438 | * 439 | * @param client Cloudflare metrics client. 440 | * @param state Current exporter state. 441 | * @returns Array of metric definitions. 442 | */ 443 | private async fetchZoneScopedMetrics( 444 | client: ReturnType, 445 | state: MetricExporterState, 446 | ): Promise { 447 | const { queryName, zoneMetadata } = state; 448 | 449 | if (zoneMetadata === null) { 450 | return []; 451 | } 452 | 453 | switch (queryName) { 454 | case "ssl-certificates": 455 | return client.getSSLCertificateMetricsForZone(zoneMetadata); 456 | case "lb-weight-metrics": 457 | return client.getLbWeightMetricsForZone(zoneMetadata); 458 | default: 459 | console.error("Unknown zone-scoped query", { queryName }); 460 | return []; 461 | } 462 | } 463 | 464 | /** 465 | * Return cached accumulated metrics. 466 | * 467 | * @returns Current snapshot of metrics with accumulated counter values. 468 | */ 469 | async export(): Promise { 470 | const state = this.getState(); 471 | return state.metrics; 472 | } 473 | 474 | /** 475 | * Process raw metrics and accumulate counter values. 476 | * 477 | * @param rawMetrics Raw metrics from Cloudflare API. 478 | * @param existingCounters Existing counter state. 479 | * @returns Processed metrics with accumulated counter values and updated counter state. 480 | */ 481 | private processCounters( 482 | rawMetrics: MetricDefinition[], 483 | existingCounters: Record, 484 | ): { metrics: MetricDefinition[]; counters: Record } { 485 | const newCounters: Record = { ...existingCounters }; 486 | 487 | const metrics = rawMetrics.map((metric) => { 488 | if (metric.type !== "counter") { 489 | return metric; 490 | } 491 | 492 | const processedValues: MetricValue[] = metric.values.map((value) => { 493 | const key = metricKey(metric.name, value.labels); 494 | newCounters[key] = this.updateCounter(newCounters[key], value.value); 495 | return { labels: value.labels, value: newCounters[key].accumulated }; 496 | }); 497 | 498 | return { ...metric, values: processedValues }; 499 | }); 500 | 501 | return { metrics, counters: newCounters }; 502 | } 503 | 504 | /** 505 | * Update counter state with a new raw value. 506 | * Cloudflare API returns window-based totals, so we simply add them. 507 | * 508 | * @param existing Existing counter state or undefined for new counter. 509 | * @param rawValue Window total from API to add to accumulated value. 510 | * @returns Updated counter state with accumulated value. 511 | */ 512 | private updateCounter( 513 | existing: CounterState | undefined, 514 | rawValue: number, 515 | ): CounterState { 516 | if (!existing) { 517 | return { accumulated: rawValue }; 518 | } 519 | return { accumulated: existing.accumulated + rawValue }; 520 | } 521 | } 522 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Cloudflare Prometheus Exporter 2 | 3 | [![Cloudflare Prometheus Exporter](https://github.com/user-attachments/assets/33794cd1-f03d-4382-9bb6-83d77cd01de5)](https://github.com/cloudflare/cloudflare-prometheus-exporter) 4 | 5 | Export Cloudflare metrics to Prometheus. Built on Cloudflare Workers with Durable Objects for stateful metric accumulation. 6 | 7 | [![Deploy to Cloudflare Workers](https://deploy.workers.cloudflare.com/button)](https://deploy.workers.cloudflare.com/?url=https://github.com/cloudflare/cloudflare-prometheus-exporter) 8 | 9 | ## Features 10 | 11 | - **58 Prometheus metrics** - requests, bandwidth, threats, workers, load balancers, SSL certs, and more 12 | - **Cloudflare Workers** - serverless edge deployment 13 | - **Durable Objects** - stateful counter accumulation for proper Prometheus semantics 14 | - **Background refresh** - alarms fetch data every 60s; scrapes return cached data instantly 15 | - **Rate limiting** - 40 req/10s with exponential backoff 16 | - **Multi-account** - automatically discovers and exports all accessible accounts/zones 17 | - **Runtime config API** - change settings without redeployment via REST endpoints 18 | - **Configurable** - zone filtering, metric denylist, label exclusion, custom metrics path, and more 19 | 20 | ## Quick Start 21 | 22 | ### One-Click Deploy 23 | 24 | Click the deploy button above. Configure `CLOUDFLARE_API_TOKEN` as a secret after deployment. Configure `BASIC_AUTH_USER` and `BASIC_AUTH_PASSWORD` to protect the exporter with HTTP Basic Auth. 25 | 26 | ### Manual Deployment 27 | 28 | ```bash 29 | git clone https://github.com/cloudflare/cloudflare-prometheus-exporter.git 30 | cd cloudflare-prometheus-exporter 31 | bun install 32 | wrangler secret put CLOUDFLARE_API_TOKEN 33 | bun run deploy 34 | ``` 35 | 36 | ## Configuration 37 | 38 | Configuration is resolved in order: **KV overrides** → **env vars** → **defaults**. Use the [Runtime Config API](#runtime-config-api) for dynamic changes without redeployment. 39 | 40 | ### Environment Variables 41 | 42 | Set in `wrangler.jsonc` or via `wrangler secret put`: 43 | 44 | | Variable | Default | Description | 45 | |----------|---------|-------------| 46 | | `CLOUDFLARE_API_TOKEN` | - | Cloudflare API token (secret) | 47 | | `QUERY_LIMIT` | 10000 | Max results per GraphQL query | 48 | | `SCRAPE_DELAY_SECONDS` | 300 | Delay before fetching metrics (data propagation) | 49 | | `TIME_WINDOW_SECONDS` | 60 | Query time window | 50 | | `METRIC_REFRESH_INTERVAL_SECONDS` | 60 | Background refresh interval | 51 | | `LOG_LEVEL` | info | Log level (debug/info/warn/error) | 52 | | `LOG_FORMAT` | json | Log format (pretty/json) | 53 | | `ACCOUNT_LIST_CACHE_TTL_SECONDS` | 600 | Account list cache TTL | 54 | | `ZONE_LIST_CACHE_TTL_SECONDS` | 1800 | Zone list cache TTL | 55 | | `SSL_CERTS_CACHE_TTL_SECONDS` | 1800 | SSL cert cache TTL | 56 | | `HEALTH_CHECK_CACHE_TTL_SECONDS` | 10 | Health check cache TTL | 57 | | `EXCLUDE_HOST` | false | Exclude host labels from metrics | 58 | | `CF_HTTP_STATUS_GROUP` | false | Group HTTP status codes (2xx, 4xx, etc.) | 59 | | `DISABLE_UI` | false | Disable landing page (returns 404) | 60 | | `DISABLE_CONFIG_API` | false | Disable config API endpoints (returns 404) | 61 | | `METRICS_DENYLIST` | - | Comma-separated list of metrics to exclude | 62 | | `CF_ACCOUNTS` | - | Comma-separated account IDs to include (default: all) | 63 | | `CF_ZONES` | - | Comma-separated zone IDs to include (default: all) | 64 | | `CF_FREE_TIER_ACCOUNTS` | - | Comma-separated account IDs using free tier (skips paid-tier metrics) | 65 | | `METRICS_PATH` | /metrics | Custom path for metrics endpoint | 66 | | `BASIC_AUTH_USER` | - | Username for basic auth (secret, default: no auth, requires `BASIC_AUTH_PASSWORD`) | 67 | | `BASIC_AUTH_PASSWORD` | - | Password for basic auth (secret, default: no auth, requires `BASIC_AUTH_USER`) | 68 | 69 | ### Creating an API Token 70 | 71 | **Quick setup**: [Create token with pre-filled permissions](https://dash.cloudflare.com/profile/api-tokens?permissionGroupKeys=%5B%7B%22key%22%3A%22analytics%22%2C%22type%22%3A%22read%22%7D%2C%7B%22key%22%3A%22account_analytics%22%2C%22type%22%3A%22read%22%7D%2C%7B%22key%22%3A%22workers_scripts%22%2C%22type%22%3A%22read%22%7D%2C%7B%22key%22%3A%22ssl_and_certificates%22%2C%22type%22%3A%22read%22%7D%2C%7B%22key%22%3A%22firewall_services%22%2C%22type%22%3A%22read%22%7D%2C%7B%22key%22%3A%22load_balancers%22%2C%22type%22%3A%22read%22%7D%2C%7B%22key%22%3A%22account_logs%22%2C%22type%22%3A%22read%22%7D%2C%7B%22key%22%3A%22magic_transit%22%2C%22type%22%3A%22read%22%7D%5D&name=Cloudflare%20Prometheus%20Exporter) 72 | 73 | **Manual setup**: 74 | 75 | | Permission | Access | Required | 76 | |------------|--------|----------| 77 | | Zone > Analytics | Read | Yes | 78 | | Account > Account Analytics | Read | Yes | 79 | | Account > Workers Scripts | Read | Yes | 80 | | Zone > SSL and Certificates | Read | Optional | 81 | | Zone > Firewall Services | Read | Optional | 82 | | Zone > Load Balancers | Read | Optional | 83 | | Account > Logs | Read | Optional | 84 | | Account > Magic Transit | Read | Optional | 85 | 86 | ## Endpoints 87 | 88 | | Path | Method | Description | 89 | |------|--------|-------------| 90 | | `/` | GET | Landing page (disable: `DISABLE_UI`) | 91 | | `/metrics` | GET | Prometheus metrics | 92 | | `/health` | GET | Health check (`{"status":"healthy"}`) | 93 | | `/config` | GET | Get all runtime config (disable: `DISABLE_CONFIG_API`) | 94 | | `/config` | DELETE | Reset all config to env defaults (disable: `DISABLE_CONFIG_API`) | 95 | | `/config/:key` | GET | Get single config value (disable: `DISABLE_CONFIG_API`) | 96 | | `/config/:key` | PUT | Set config override (persisted in KV) (disable: `DISABLE_CONFIG_API`) | 97 | | `/config/:key` | DELETE | Reset config key to env default (disable: `DISABLE_CONFIG_API`) | 98 | 99 | ## Prometheus Configuration 100 | 101 | ```yaml 102 | scrape_configs: 103 | - job_name: 'cloudflare' 104 | scrape_interval: 60s 105 | scrape_timeout: 30s 106 | static_configs: 107 | - targets: ['your-worker.your-subdomain.workers.dev'] 108 | ``` 109 | 110 | ### With Basic Auth 111 | 112 | Set up basic auth to protect all endpoints: 113 | 114 | ```bash 115 | wrangler secret put BASIC_AUTH_USER 116 | wrangler secret put BASIC_AUTH_PASSWORD 117 | ``` 118 | 119 | Then configure Prometheus: 120 | 121 | ```yaml 122 | scrape_configs: 123 | - job_name: 'cloudflare' 124 | scrape_interval: 60s 125 | scrape_timeout: 30s 126 | basic_auth: 127 | username: 'your-username' 128 | password: 'your-password' 129 | static_configs: 130 | - targets: ['your-worker.your-subdomain.workers.dev'] 131 | ``` 132 | 133 | ## Runtime Config API 134 | 135 | Override configuration at runtime without redeployment. Overrides persist in KV and take precedence over `wrangler.jsonc` env vars. 136 | 137 | ### Config Keys 138 | 139 | | Key | Type | Description | 140 | |-----|------|-------------| 141 | | `queryLimit` | number | Max results per GraphQL query | 142 | | `scrapeDelaySeconds` | number | Delay before fetching metrics | 143 | | `timeWindowSeconds` | number | Query time window | 144 | | `metricRefreshIntervalSeconds` | number | Background refresh interval | 145 | | `accountListCacheTtlSeconds` | number | Account list cache TTL | 146 | | `zoneListCacheTtlSeconds` | number | Zone list cache TTL | 147 | | `sslCertsCacheTtlSeconds` | number | SSL cert cache TTL | 148 | | `healthCheckCacheTtlSeconds` | number | Health check cache TTL | 149 | | `logFormat` | `"json"` \| `"pretty"` | Log format | 150 | | `logLevel` | `"debug"` \| `"info"` \| `"warn"` \| `"error"` | Log level | 151 | | `cfAccounts` | string \| null | Comma-separated account IDs (null = all) | 152 | | `cfZones` | string \| null | Comma-separated zone IDs (null = all) | 153 | | `cfFreeTierAccounts` | string | Comma-separated free tier account IDs | 154 | | `metricsDenylist` | string | Comma-separated metrics to exclude | 155 | | `excludeHost` | boolean | Exclude host labels | 156 | | `httpStatusGroup` | boolean | Group HTTP status codes | 157 | 158 | ### Examples 159 | 160 | ```bash 161 | # Get all config 162 | curl https://your-worker.workers.dev/config 163 | 164 | # Get single value 165 | curl https://your-worker.workers.dev/config/logLevel 166 | 167 | # Set override 168 | curl -X PUT https://your-worker.workers.dev/config/logLevel \ 169 | -H "Content-Type: application/json" \ 170 | -d '{"value": "debug"}' 171 | 172 | # Filter to specific zones 173 | curl -X PUT https://your-worker.workers.dev/config/cfZones \ 174 | -H "Content-Type: application/json" \ 175 | -d '{"value": "zone-id-1,zone-id-2"}' 176 | 177 | # Reset to env default 178 | curl -X DELETE https://your-worker.workers.dev/config/logLevel 179 | 180 | # Reset all overrides 181 | curl -X DELETE https://your-worker.workers.dev/config 182 | ``` 183 | 184 | ## Available Metrics 185 | 186 | ### Zone Request Metrics 187 | 188 | | Metric | Type | Labels | 189 | |--------|------|--------| 190 | | `cloudflare_zone_requests_total` | counter | zone | 191 | | `cloudflare_zone_requests_cached` | gauge | zone | 192 | | `cloudflare_zone_requests_ssl_encrypted_total` | counter | zone | 193 | | `cloudflare_zone_requests_content_type_total` | counter | zone, content_type | 194 | | `cloudflare_zone_requests_country_total` | counter | zone, country | 195 | | `cloudflare_zone_requests_status_total` | counter | zone, status | 196 | | `cloudflare_zone_requests_browser_map_page_views_total` | counter | zone, family | 197 | | `cloudflare_zone_requests_ip_class_total` | counter | zone, ip_class | 198 | | `cloudflare_zone_requests_ssl_protocol_total` | counter | zone, ssl_protocol | 199 | | `cloudflare_zone_requests_http_version_total` | counter | zone, http_version | 200 | | `cloudflare_zone_requests_origin_status_country_host_total` | counter | zone, origin_status, country, host | 201 | | `cloudflare_zone_requests_status_country_host_total` | counter | zone, edge_status, country, host | 202 | | `cloudflare_zone_requests_by_method_total` | counter | zone, method | 203 | 204 | ### Zone Bandwidth Metrics 205 | 206 | | Metric | Type | Labels | 207 | |--------|------|--------| 208 | | `cloudflare_zone_bandwidth_total` | counter | zone | 209 | | `cloudflare_zone_bandwidth_cached_total` | counter | zone | 210 | | `cloudflare_zone_bandwidth_ssl_encrypted_total` | counter | zone | 211 | | `cloudflare_zone_bandwidth_content_type_total` | counter | zone, content_type | 212 | | `cloudflare_zone_bandwidth_country_total` | counter | zone, country | 213 | 214 | ### Zone Threat Metrics 215 | 216 | | Metric | Type | Labels | 217 | |--------|------|--------| 218 | | `cloudflare_zone_threats_total` | counter | zone | 219 | | `cloudflare_zone_threats_country_total` | counter | zone, country | 220 | | `cloudflare_zone_threats_type_total` | counter | zone, type | 221 | 222 | ### Zone Page/Unique Metrics 223 | 224 | | Metric | Type | Labels | 225 | |--------|------|--------| 226 | | `cloudflare_zone_pageviews_total` | counter | zone | 227 | | `cloudflare_zone_uniques_total` | counter | zone | 228 | 229 | ### Colocation Metrics 230 | 231 | | Metric | Type | Labels | 232 | |--------|------|--------| 233 | | `cloudflare_zone_colocation_visits_total` | counter | zone, colo, host | 234 | | `cloudflare_zone_colocation_edge_response_bytes_total` | counter | zone, colo, host | 235 | | `cloudflare_zone_colocation_requests_total` | counter | zone, colo, host | 236 | | `cloudflare_zone_colocation_error_visits_total` | counter | zone, colo, host, status | 237 | | `cloudflare_zone_colocation_error_edge_response_bytes_total` | counter | zone, colo, host, status | 238 | | `cloudflare_zone_colocation_error_requests_total` | counter | zone, colo, host, status | 239 | 240 | ### Firewall Metrics 241 | 242 | | Metric | Type | Labels | 243 | |--------|------|--------| 244 | | `cloudflare_zone_firewall_events_total` | counter | zone, action, source, rule, host, country | 245 | | `cloudflare_zone_firewall_bots_detected_total` | counter | zone, bot_score, detection_source | 246 | 247 | ### Health Check Metrics 248 | 249 | | Metric | Type | Labels | 250 | |--------|------|--------| 251 | | `cloudflare_zone_health_check_events_origin_total` | counter | zone, health_status, origin_ip, region, fqdn, failure_reason | 252 | | `cloudflare_zone_health_check_events_avg` | gauge | zone | 253 | | `cloudflare_zone_health_check_rtt_seconds` | gauge | zone, origin_ip, fqdn | 254 | | `cloudflare_zone_health_check_ttfb_seconds` | gauge | zone, origin_ip, fqdn | 255 | | `cloudflare_zone_health_check_tcp_connection_seconds` | gauge | zone, origin_ip, fqdn | 256 | | `cloudflare_zone_health_check_tls_handshake_seconds` | gauge | zone, origin_ip, fqdn | 257 | 258 | ### Worker Metrics 259 | 260 | | Metric | Type | Labels | 261 | |--------|------|--------| 262 | | `cloudflare_worker_requests_total` | counter | script_name | 263 | | `cloudflare_worker_errors_total` | counter | script_name | 264 | | `cloudflare_worker_cpu_time_seconds` | gauge | script_name, quantile | 265 | | `cloudflare_worker_duration_seconds` | gauge | script_name, quantile | 266 | 267 | ### Load Balancer Metrics 268 | 269 | | Metric | Type | Labels | 270 | |--------|------|--------| 271 | | `cloudflare_zone_pool_health_status` | gauge | zone, lb_name, pool_name | 272 | | `cloudflare_zone_pool_requests_total` | counter | zone, lb_name, pool_name, origin_name | 273 | | `cloudflare_zone_lb_pool_rtt_seconds` | gauge | zone, lb_name, pool_name | 274 | | `cloudflare_zone_lb_steering_policy_info` | gauge | zone, lb_name, policy | 275 | | `cloudflare_zone_lb_origins_selected_count` | gauge | zone, lb_name, pool_name | 276 | | `cloudflare_zone_lb_origin_weight` | gauge | zone, lb_name, pool_name, origin_name | 277 | 278 | ### Logpush Metrics 279 | 280 | | Metric | Type | Labels | 281 | |--------|------|--------| 282 | | `cloudflare_logpush_failed_jobs_account_total` | counter | account, job_id, status, destination_type | 283 | | `cloudflare_logpush_failed_jobs_zone_total` | counter | zone, job_id, destination_type | 284 | 285 | ### Error Rate Metrics 286 | 287 | | Metric | Type | Labels | 288 | |--------|------|--------| 289 | | `cloudflare_zone_customer_error_4xx_total` | counter | zone, status, country, host | 290 | | `cloudflare_zone_customer_error_5xx_total` | counter | zone, status, country, host | 291 | | `cloudflare_zone_edge_error_rate` | gauge | zone | 292 | | `cloudflare_zone_origin_error_rate` | gauge | zone | 293 | | `cloudflare_zone_origin_response_duration_seconds` | gauge | zone, status, country, host | 294 | 295 | ### Cache Metrics 296 | 297 | | Metric | Type | Labels | 298 | |--------|------|--------| 299 | | `cloudflare_zone_cache_hit_ratio` | gauge | zone | 300 | | `cloudflare_zone_cache_miss_origin_duration_seconds` | gauge | zone, country, host | 301 | 302 | ### Bot Metrics 303 | 304 | | Metric | Type | Labels | 305 | |--------|------|--------| 306 | | `cloudflare_zone_bot_requests_by_country_total` | counter | zone, country | 307 | 308 | ### Magic Transit Metrics 309 | 310 | | Metric | Type | Labels | 311 | |--------|------|--------| 312 | | `cloudflare_magic_transit_active_tunnels` | gauge | account | 313 | | `cloudflare_magic_transit_healthy_tunnels` | gauge | account | 314 | | `cloudflare_magic_transit_tunnel_failures` | gauge | account | 315 | | `cloudflare_magic_transit_edge_colo_count` | gauge | account | 316 | 317 | ### SSL Certificate Metrics 318 | 319 | | Metric | Type | Labels | 320 | |--------|------|--------| 321 | | `cloudflare_zone_certificate_validation_status` | gauge | zone, type, issuer, status | 322 | 323 | ### Exporter Info Metrics 324 | 325 | | Metric | Type | Labels | 326 | |--------|------|--------| 327 | | `cloudflare_exporter_up` | gauge | - | 328 | | `cloudflare_exporter_errors_total` | counter | account_id, error_code | 329 | | `cloudflare_accounts` | gauge | - | 330 | | `cloudflare_zones` | gauge | - | 331 | | `cloudflare_zones_filtered` | gauge | - | 332 | | `cloudflare_zones_processed` | gauge | - | 333 | | `cloudflare_zones_skipped_free_tier` | gauge | - | 334 | 335 | ## Free Tier Zone Limitations 336 | 337 | 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. 338 | 339 | **Free tier zones still export:** 340 | - `cloudflare_zone_certificate_validation_status` (SSL certificates) 341 | - `cloudflare_zone_lb_origin_weight` (Load balancer weights, if configured) 342 | 343 | **Monitor skipped zones:** 344 | ``` 345 | cloudflare_zones_skipped_free_tier 346 | ``` 347 | 348 | For mixed accounts (enterprise + free zones), only free zones are skipped—paid zones continue to export all metrics. 349 | 350 | ## Architecture 351 | 352 | 353 | ``` 354 | ┌────────────────────────────────────────────────────────────────────────────────┐ 355 | │ WORKER ISOLATE │ 356 | │ ┌────────────────┐ │ 357 | │ │ Worker.fetch │◄─── HTTP /metrics, /health, /config │ 358 | │ │ (HTTP handler) │ │ 359 | │ └───────┬────────┘ │ 360 | │ │ │ 361 | │ │ RPC (stub.export()) │ 362 | │ ▼ │ 363 | │ ┌────────────────────────────────────────────────────────────────────────┐ │ 364 | │ │ CONFIG_KV: Runtime config overrides (merged with env defaults) │ │ 365 | │ └────────────────────────────────────────────────────────────────────────┘ │ 366 | └──────────┼─────────────────────────────────────────────────────────────────────┘ 367 | │ 368 | │ 369 | ▼ 370 | ┌────────────────────────────────────────────────────────────────────────────────┐ 371 | │ DURABLE OBJECT ISOLATES │ 372 | │ │ 373 | │ Each DO runs in its own V8 isolate with: │ 374 | │ - Own CloudflareMetricsClient instance (per-isolate singleton) │ 375 | │ - Own persistent storage │ 376 | │ - Own alarm scheduler │ 377 | │ │ 378 | │ ┌─────────────────────────────────────────────────────────────────────────┐ │ 379 | │ │ MetricCoordinator (1 global instance) │ │ 380 | │ │ ID: "metric-coordinator" │ │ 381 | │ │ State: accounts[], lastAccountFetch │ │ 382 | │ │ Cache TTL: 600s (account list) │ │ 383 | │ └─────────────────────────────────────────────────────────────────────────┘ │ 384 | │ │ RPC │ 385 | │ ┌────────────┼────────────┐ │ 386 | │ ▼ ▼ ▼ │ 387 | │ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │ 388 | │ │ AccountMetric │ │ AccountMetric │ │ AccountMetric │ │ 389 | │ │ Coordinator │ │ Coordinator │ │ Coordinator │ │ 390 | │ │ account:acct1 │ │ account:acct2 │ │ account:acct3 │ │ 391 | │ │ Alarm: 60s │ │ Alarm: 60s │ │ Alarm: 60s │ │ 392 | │ │ Zone TTL: 1800s │ │ Zone TTL: 1800s │ │ Zone TTL: 1800s │ │ 393 | │ └───────┬─────────┘ └───────┬─────────┘ └───────┬─────────┘ │ 394 | │ │ RPC │ │ │ 395 | │ ┌──────┴─────┐ ┌──────┴─────┐ ┌──────┴─────┐ │ 396 | │ ▼ ▼ ▼ ▼ ▼ ▼ │ 397 | │ ┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐ │ 398 | │ │Exprt│ │Exprt│ │Exprt│ │Exprt│ │Exprt│ │Exprt│ │ 399 | │ │(13) │ .. │(N) │ │(13) │ .. │(N) │ │(13) │ .. │(N) │ │ 400 | │ │acct │ │zone │ │acct │ │zone │ │acct │ │zone │ │ 401 | │ └─────┘ └─────┘ └─────┘ └─────┘ └─────┘ └─────┘ │ 402 | │ │ 403 | │ MetricExporter DOs (per account): │ 404 | │ - Account-scoped (13): worker-totals, logpush-account, magic-transit, │ 405 | │ http-metrics, adaptive-metrics, edge-country-metrics, colo-metrics, │ 406 | │ colo-error-metrics, request-method-metrics, health-check-metrics, │ 407 | │ load-balancer-metrics, logpush-zone, origin-status-metrics │ 408 | │ - Zone-scoped (N per account, 1 per zone): ssl-certificates │ 409 | │ │ 410 | │ ┌─────────────────────────────────────────────────────────────────────────┐ │ 411 | │ │ CloudflareMetricsClient (per-isolate) │ │ 412 | │ │ - urql Client (GraphQL) │ │ 413 | │ │ - Cloudflare SDK (REST) │ │ 414 | │ │ - DataLoader: firewallRulesLoader (batches Promise.all calls) │ │ 415 | │ │ - Global Rate limiter: 40 req/10s with exponential backoff │ │ 416 | │ └─────────────────────────────────────────────────────────────────────────┘ │ 417 | └────────────────────────────────────────────────────────────────────────────────┘ 418 | ``` 419 | 420 | ### Request Path: Prometheus Scrape (GET /metrics) 421 | 422 | ``` 423 | ┌──────────┐ GET /metrics ┌────────┐ 424 | │Prometheus│────────────────▶│ Worker │ 425 | │ Server │ │ .fetch │ 426 | └──────────┘ └───┬────┘ 427 | │ 428 | ┌──────────────────────┴──────────────────────┐ 429 | │ MetricCoordinator │ 430 | │ │ 431 | │ 1. Check account cache (TTL: 600s) │ 432 | │ 2. If stale → getAccounts() │ 433 | │ 3. Fan out to AccountMetricCoordinators │ 434 | └─────────────────────┬───────────────────────┘ 435 | │ 436 | ┌────────────────────────┼────────────────────────┐ 437 | │ │ │ 438 | ▼ ▼ ▼ 439 | ┌────────────────┐ ┌────────────────┐ ┌────────────────┐ 440 | │ AccountMetric │ │ AccountMetric │ │ AccountMetric │ 441 | │ Coordinator │ │ Coordinator │ │ Coordinator │ 442 | │ (Account A) │ │ (Account B) │ │ (Account C) │ 443 | │ │ │ │ │ │ 444 | │ 1. Check if │ │ │ │ │ 445 | │ refresh() │ │ (parallel) │ │ (parallel) │ 446 | │ needed │ │ │ │ │ 447 | │ 2. Fan out to │ │ │ │ │ 448 | │ exporters │ │ │ │ │ 449 | └───────┬────────┘ └───────┬────────┘ └───────┬────────┘ 450 | │ │ │ 451 | ┌─────┴─────┐ ┌─────┴─────┐ ┌─────┴─────┐ 452 | ▼ ▼ ▼ ▼ ▼ ▼ 453 | ┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐ 454 | │Exprt│...│Exprt│ │Exprt│...│Exprt│ │Exprt│...│Exprt│ 455 | │13+N │ │ │ │13+N │ │ │ │13+N │ │ │ 456 | │ │ │ │ │ │ │ │ │ │ │ │ 457 | │ ret │ │ ret │ │ ret │ │ ret │ │ ret │ │ ret │ 458 | │cache│ │cache│ │cache│ │cache│ │cache│ │cache│ 459 | └──┬──┘ └──┬──┘ └──┬──┘ └──┬──┘ └──┬──┘ └──┬──┘ 460 | │ │ │ │ │ │ 461 | └────┬────┘ └────┬────┘ └────┬────┘ 462 | │ │ │ 463 | └────────────────────┼────────────────────┘ 464 | │ 465 | ▼ 466 | ┌─────────────────┐ 467 | │ FAN-IN: Merge │ 468 | │ all metrics + │ 469 | │ serialize to │ 470 | │ Prometheus fmt │ 471 | └────────┬────────┘ 472 | │ 473 | ▼ 474 | ┌─────────────────┐ 475 | │ HTTP Response │ 476 | │ text/plain │ 477 | └─────────────────┘ 478 | 479 | ┌──────────────────────────────────────────────────────────┐ 480 | │ NOTE: Request path is FAST - just reads cached metrics │ 481 | │ No network calls to Cloudflare API during scrape │ 482 | │ (unless account list cache is stale) │ 483 | └──────────────────────────────────────────────────────────┘ 484 | ``` 485 | 486 | ### Background Refresh Path: Alarm-Driven Metric Fetching 487 | 488 | ``` 489 | ┌──────────────────────────────────────────────┐ 490 | │ ALARM TRIGGERS │ 491 | │ AccountMetricCoordinator: every 60s │ 492 | │ MetricExporter: every 60s + 1-5s fixed jitter│ 493 | └──────────────────────────────────────────────┘ 494 | ``` 495 | 496 | **AccountMetricCoordinator.alarm()** 497 | 498 | ``` 499 | ┌────────────────────────────────────────────────────────────────────────┐ 500 | │ AccountMetricCoordinator.refresh() │ 501 | │ │ 502 | │ 1. Check zone cache (TTL: 1800s / 30 min) │ 503 | │ │ 504 | │ 2. If stale: │ 505 | │ ┌────────────────────────────────────────────────────────────────┐ │ 506 | │ │ REST: getZones(accountId) │ │ 507 | │ │ └─► DataLoader batches if multiple calls same tick │ │ 508 | │ └────────────────────────────────────────────────────────────────┘ │ 509 | │ ┌────────────────────────────────────────────────────────────────┐ │ 510 | │ │ REST: getFirewallRules(zoneId) × N zones (parallel) │ │ 511 | │ │ └─► DataLoader batches parallel calls │ │ 512 | │ └────────────────────────────────────────────────────────────────┘ │ 513 | │ │ 514 | │ 3. Push context to MetricExporter DOs: │ 515 | │ ┌────────────────────────────────────────────────────────────────┐ │ 516 | │ │ Account-scoped (13 exporters): │ │ 517 | │ │ exporter.updateZoneContext(accountId, accountName, zones) │ │ 518 | │ │ │ │ 519 | │ │ Zone-scoped (N exporters, 1 per zone): │ │ 520 | │ │ exporter.initializeZone(zone, accountId, accountName) │ │ 521 | │ └────────────────────────────────────────────────────────────────┘ │ 522 | │ │ 523 | │ 4. Schedule next alarm (60s) │ 524 | └────────────────────────────────────────────────────────────────────────┘ 525 | ``` 526 | 527 | **MetricExporter.alarm()** 528 | 529 | ``` 530 | ┌────────────────────────────────────────────────────────────────────────┐ 531 | │ MetricExporter.refresh() for account-scoped queries │ 532 | │ │ 533 | │ Query Types (13 total): │ 534 | │ ├── ACCOUNT-LEVEL (single account per query, 3): │ 535 | │ │ ├── worker-totals │ 536 | │ │ ├── logpush-account │ 537 | │ │ └── magic-transit │ 538 | │ │ │ 539 | │ └── ZONE-LEVEL (all zones batched in one query, 10): │ 540 | │ ├── http-metrics │ 541 | │ ├── adaptive-metrics │ 542 | │ ├── edge-country-metrics │ 543 | │ ├── colo-metrics │ 544 | │ ├── colo-error-metrics │ 545 | │ ├── request-method-metrics │ 546 | │ ├── health-check-metrics │ 547 | │ ├── load-balancer-metrics │ 548 | │ ├── logpush-zone │ 549 | │ └── origin-status-metrics │ 550 | │ │ 551 | │ After fetch: Process counters → Cache metrics → Schedule next alarm │ 552 | │ Jitter: 1-5s fixed (tighter clustering for time range alignment) │ 553 | └────────────────────────────────────────────────────────────────────────┘ 554 | ``` 555 | 556 | ## Development 557 | 558 | ```bash 559 | bun install # Install dependencies 560 | bun run dev # Run locally (port 8787) 561 | bun run check # Lint + format check 562 | bun run deploy # Deploy to Cloudflare 563 | ``` 564 | 565 | ## Tech Stack 566 | 567 | - **[Hono](https://hono.dev/)** - Web framework 568 | - **[urql](https://formidable.com/open-source/urql/)** - GraphQL client 569 | - **[gql.tada](https://gql-tada.0no.co/)** - Type-safe GraphQL 570 | - **[Zod](https://zod.dev/)** - Schema validation 571 | - **[DataLoader](https://github.com/graphql/dataloader)** - Request batching 572 | - **[Cloudflare SDK](https://developers.cloudflare.com/api/)** - REST API client 573 | - **[Cloudflare KV](https://developers.cloudflare.com/kv/)** - Runtime config persistence 574 | 575 | ## License 576 | 577 | MIT 578 | -------------------------------------------------------------------------------- /bun.lock: -------------------------------------------------------------------------------- 1 | { 2 | "lockfileVersion": 1, 3 | "configVersion": 0, 4 | "workspaces": { 5 | "": { 6 | "name": "cloudflare-prometheus-exporter-v2", 7 | "dependencies": { 8 | "@urql/core": "^6.0.1", 9 | "cloudflare": "^5.2.0", 10 | "consola": "^3.4.2", 11 | "dataloader": "^2.2.3", 12 | "gql.tada": "^1.9.0", 13 | "graphql": "^16.12.0", 14 | "hono": "^4.10.7", 15 | "install": "^0.13.0", 16 | "zod": "^4.1.13", 17 | }, 18 | "devDependencies": { 19 | "@biomejs/biome": "^2.3.8", 20 | "typescript": "^5.9.3", 21 | "wrangler": "^4.51.0", 22 | }, 23 | }, 24 | }, 25 | "packages": { 26 | "@0no-co/graphql.web": ["@0no-co/graphql.web@1.2.0", "", { "peerDependencies": { "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0" }, "optionalPeers": ["graphql"] }, "sha512-/1iHy9TTr63gE1YcR5idjx8UREz1s0kFhydf3bBLCXyqjhkIc6igAzTOx3zPifCwFR87tsh/4Pa9cNts6d2otw=="], 27 | 28 | "@0no-co/graphqlsp": ["@0no-co/graphqlsp@1.15.1", "", { "dependencies": { "@gql.tada/internal": "^1.0.0", "graphql": "^15.5.0 || ^16.0.0 || ^17.0.0" }, "peerDependencies": { "typescript": "^5.0.0" } }, "sha512-UBDBuVGpX5Ti0PjGnSAzkMG04psNYxKfJ+1bgF8HFPfHHpKNVl4GULHSNW0GTOngcYCYA70c+InoKw0qjHwmVQ=="], 29 | 30 | "@biomejs/biome": ["@biomejs/biome@2.3.8", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.3.8", "@biomejs/cli-darwin-x64": "2.3.8", "@biomejs/cli-linux-arm64": "2.3.8", "@biomejs/cli-linux-arm64-musl": "2.3.8", "@biomejs/cli-linux-x64": "2.3.8", "@biomejs/cli-linux-x64-musl": "2.3.8", "@biomejs/cli-win32-arm64": "2.3.8", "@biomejs/cli-win32-x64": "2.3.8" }, "bin": { "biome": "bin/biome" } }, "sha512-Qjsgoe6FEBxWAUzwFGFrB+1+M8y/y5kwmg5CHac+GSVOdmOIqsAiXM5QMVGZJ1eCUCLlPZtq4aFAQ0eawEUuUA=="], 31 | 32 | "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.3.8", "", { "os": "darwin", "cpu": "arm64" }, "sha512-HM4Zg9CGQ3txTPflxD19n8MFPrmUAjaC7PQdLkugeeC0cQ+PiVrd7i09gaBS/11QKsTDBJhVg85CEIK9f50Qww=="], 33 | 34 | "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.3.8", "", { "os": "darwin", "cpu": "x64" }, "sha512-lUDQ03D7y/qEao7RgdjWVGCu+BLYadhKTm40HkpJIi6kn8LSv5PAwRlew/DmwP4YZ9ke9XXoTIQDO1vAnbRZlA=="], 35 | 36 | "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.3.8", "", { "os": "linux", "cpu": "arm64" }, "sha512-Uo1OJnIkJgSgF+USx970fsM/drtPcQ39I+JO+Fjsaa9ZdCN1oysQmy6oAGbyESlouz+rzEckLTF6DS7cWse95g=="], 37 | 38 | "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.3.8", "", { "os": "linux", "cpu": "arm64" }, "sha512-PShR4mM0sjksUMyxbyPNMxoKFPVF48fU8Qe8Sfx6w6F42verbwRLbz+QiKNiDPRJwUoMG1nPM50OBL3aOnTevA=="], 39 | 40 | "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.3.8", "", { "os": "linux", "cpu": "x64" }, "sha512-QDPMD5bQz6qOVb3kiBui0zKZXASLo0NIQ9JVJio5RveBEFgDgsvJFUvZIbMbUZT3T00M/1wdzwWXk4GIh0KaAw=="], 41 | 42 | "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.3.8", "", { "os": "linux", "cpu": "x64" }, "sha512-YGLkqU91r1276uwSjiUD/xaVikdxgV1QpsicT0bIA1TaieM6E5ibMZeSyjQ/izBn4tKQthUSsVZacmoJfa3pDA=="], 43 | 44 | "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.3.8", "", { "os": "win32", "cpu": "arm64" }, "sha512-H4IoCHvL1fXKDrTALeTKMiE7GGWFAraDwBYFquE/L/5r1927Te0mYIGseXi4F+lrrwhSWbSGt5qPFswNoBaCxg=="], 45 | 46 | "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.3.8", "", { "os": "win32", "cpu": "x64" }, "sha512-RguzimPoZWtBapfKhKjcWXBVI91tiSprqdBYu7tWhgN8pKRZhw24rFeNZTNf6UiBfjCYCi9eFQs/JzJZIhuK4w=="], 47 | 48 | "@cloudflare/kv-asset-handler": ["@cloudflare/kv-asset-handler@0.4.1", "", { "dependencies": { "mime": "^3.0.0" } }, "sha512-Nu8ahitGFFJztxUml9oD/DLb7Z28C8cd8F46IVQ7y5Btz575pvMY8AqZsXkX7Gds29eCKdMgIHjIvzskHgPSFg=="], 49 | 50 | "@cloudflare/unenv-preset": ["@cloudflare/unenv-preset@2.7.11", "", { "peerDependencies": { "unenv": "2.0.0-rc.24", "workerd": "^1.20251106.1" } }, "sha512-se23f1D4PxKrMKOq+Stz+Yn7AJ9ITHcEecXo2Yjb+UgbUDCEBch1FXQC6hx6uT5fNA3kmX3mfzeZiUmpK1W9IQ=="], 51 | 52 | "@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20251125.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-xDIVJi8fPxBseRoEIzLiUJb0N+DXnah/ynS+Unzn58HEoKLetUWiV/T1Fhned//lo5krnToG9KRgVRs0SOOTpw=="], 53 | 54 | "@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20251125.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-k5FQET5PXnWjeDqZUpl4Ah/Rn0bH6mjfUtTyeAy6ky7QB3AZpwIhgWQD0vOFB3OvJaK4J/K4cUtNChYXB9mY/A=="], 55 | 56 | "@cloudflare/workerd-linux-64": ["@cloudflare/workerd-linux-64@1.20251125.0", "", { "os": "linux", "cpu": "x64" }, "sha512-at6n/FomkftykWx0EqVLUZ0juUFz3ORtEPeBbW9ZZ3BQEyfVUtYfdcz/f1cN8Yyb7TE9ovF071P0mBRkx83ODw=="], 57 | 58 | "@cloudflare/workerd-linux-arm64": ["@cloudflare/workerd-linux-arm64@1.20251125.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-EiRn+jrNaIs1QveabXGHFoyn3s/l02ui6Yp3nssyNhtmtgviddtt8KObBfM1jQKjXTpZlunhwdN4Bxf4jhlOMw=="], 59 | 60 | "@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20251125.0", "", { "os": "win32", "cpu": "x64" }, "sha512-6fdIsSeu65g++k8Y2DKzNKs0BkoU+KKI6GAAVBOLh2vvVWWnCP1OgMdVb5JAdjDrjDT5i0GSQu0bgQ8fPsW6zw=="], 61 | 62 | "@cspotcode/source-map-support": ["@cspotcode/source-map-support@0.8.1", "", { "dependencies": { "@jridgewell/trace-mapping": "0.3.9" } }, "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw=="], 63 | 64 | "@emnapi/runtime": ["@emnapi/runtime@1.7.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA=="], 65 | 66 | "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.4", "", { "os": "aix", "cpu": "ppc64" }, "sha512-1VCICWypeQKhVbE9oW/sJaAmjLxhVqacdkvPLEjwlttjfwENRSClS8EjBz0KzRyFSCPDIkuXW34Je/vk7zdB7Q=="], 67 | 68 | "@esbuild/android-arm": ["@esbuild/android-arm@0.25.4", "", { "os": "android", "cpu": "arm" }, "sha512-QNdQEps7DfFwE3hXiU4BZeOV68HHzYwGd0Nthhd3uCkkEKK7/R6MTgM0P7H7FAs5pU/DIWsviMmEGxEoxIZ+ZQ=="], 69 | 70 | "@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.4", "", { "os": "android", "cpu": "arm64" }, "sha512-bBy69pgfhMGtCnwpC/x5QhfxAz/cBgQ9enbtwjf6V9lnPI/hMyT9iWpR1arm0l3kttTr4L0KSLpKmLp/ilKS9A=="], 71 | 72 | "@esbuild/android-x64": ["@esbuild/android-x64@0.25.4", "", { "os": "android", "cpu": "x64" }, "sha512-TVhdVtQIFuVpIIR282btcGC2oGQoSfZfmBdTip2anCaVYcqWlZXGcdcKIUklfX2wj0JklNYgz39OBqh2cqXvcQ=="], 73 | 74 | "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Y1giCfM4nlHDWEfSckMzeWNdQS31BQGs9/rouw6Ub91tkK79aIMTH3q9xHvzH8d0wDru5Ci0kWB8b3up/nl16g=="], 75 | 76 | "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-CJsry8ZGM5VFVeyUYB3cdKpd/H69PYez4eJh1W/t38vzutdjEjtP7hB6eLKBoOdxcAlCtEYHzQ/PJ/oU9I4u0A=="], 77 | 78 | "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.4", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-yYq+39NlTRzU2XmoPW4l5Ifpl9fqSk0nAJYM/V/WUGPEFfek1epLHJIkTQM6bBs1swApjO5nWgvr843g6TjxuQ=="], 79 | 80 | "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.4", "", { "os": "freebsd", "cpu": "x64" }, "sha512-0FgvOJ6UUMflsHSPLzdfDnnBBVoCDtBTVyn/MrWloUNvq/5SFmh13l3dvgRPkDihRxb77Y17MbqbCAa2strMQQ=="], 81 | 82 | "@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.4", "", { "os": "linux", "cpu": "arm" }, "sha512-kro4c0P85GMfFYqW4TWOpvmF8rFShbWGnrLqlzp4X1TNWjRY3JMYUfDCtOxPKOIY8B0WC8HN51hGP4I4hz4AaQ=="], 83 | 84 | "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-+89UsQTfXdmjIvZS6nUnOOLoXnkUTB9hR5QAeLrQdzOSWZvNSAXAtcRDHWtqAUtAmv7ZM1WPOOeSxDzzzMogiQ=="], 85 | 86 | "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.4", "", { "os": "linux", "cpu": "ia32" }, "sha512-yTEjoapy8UP3rv8dB0ip3AfMpRbyhSN3+hY8mo/i4QXFeDxmiYbEKp3ZRjBKcOP862Ua4b1PDfwlvbuwY7hIGQ=="], 87 | 88 | "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.4", "", { "os": "linux", "cpu": "none" }, "sha512-NeqqYkrcGzFwi6CGRGNMOjWGGSYOpqwCjS9fvaUlX5s3zwOtn1qwg1s2iE2svBe4Q/YOG1q6875lcAoQK/F4VA=="], 89 | 90 | "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.4", "", { "os": "linux", "cpu": "none" }, "sha512-IcvTlF9dtLrfL/M8WgNI/qJYBENP3ekgsHbYUIzEzq5XJzzVEV/fXY9WFPfEEXmu3ck2qJP8LG/p3Q8f7Zc2Xg=="], 91 | 92 | "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-HOy0aLTJTVtoTeGZh4HSXaO6M95qu4k5lJcH4gxv56iaycfz1S8GO/5Jh6X4Y1YiI0h7cRyLi+HixMR+88swag=="], 93 | 94 | "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.4", "", { "os": "linux", "cpu": "none" }, "sha512-i8JUDAufpz9jOzo4yIShCTcXzS07vEgWzyX3NH2G7LEFVgrLEhjwL3ajFE4fZI3I4ZgiM7JH3GQ7ReObROvSUA=="], 95 | 96 | "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-jFnu+6UbLlzIjPQpWCNh5QtrcNfMLjgIavnwPQAfoGx4q17ocOU9MsQ2QVvFxwQoWpZT8DvTLooTvmOQXkO51g=="], 97 | 98 | "@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.4", "", { "os": "linux", "cpu": "x64" }, "sha512-6e0cvXwzOnVWJHq+mskP8DNSrKBr1bULBvnFLpc1KY+d+irZSgZ02TGse5FsafKS5jg2e4pbvK6TPXaF/A6+CA=="], 99 | 100 | "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.4", "", { "os": "none", "cpu": "arm64" }, "sha512-vUnkBYxZW4hL/ie91hSqaSNjulOnYXE1VSLusnvHg2u3jewJBz3YzB9+oCw8DABeVqZGg94t9tyZFoHma8gWZQ=="], 101 | 102 | "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.4", "", { "os": "none", "cpu": "x64" }, "sha512-XAg8pIQn5CzhOB8odIcAm42QsOfa98SBeKUdo4xa8OvX8LbMZqEtgeWE9P/Wxt7MlG2QqvjGths+nq48TrUiKw=="], 103 | 104 | "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.4", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-Ct2WcFEANlFDtp1nVAXSNBPDxyU+j7+tId//iHXU2f/lN5AmO4zLyhDcpR5Cz1r08mVxzt3Jpyt4PmXQ1O6+7A=="], 105 | 106 | "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.4", "", { "os": "openbsd", "cpu": "x64" }, "sha512-xAGGhyOQ9Otm1Xu8NT1ifGLnA6M3sJxZ6ixylb+vIUVzvvd6GOALpwQrYrtlPouMqd/vSbgehz6HaVk4+7Afhw=="], 107 | 108 | "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.4", "", { "os": "sunos", "cpu": "x64" }, "sha512-Mw+tzy4pp6wZEK0+Lwr76pWLjrtjmJyUB23tHKqEDP74R3q95luY/bXqXZeYl4NYlvwOqoRKlInQialgCKy67Q=="], 109 | 110 | "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-AVUP428VQTSddguz9dO9ngb+E5aScyg7nOeJDrF1HPYu555gmza3bDGMPhmVXL8svDSoqPCsCPjb265yG/kLKQ=="], 111 | 112 | "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.4", "", { "os": "win32", "cpu": "ia32" }, "sha512-i1sW+1i+oWvQzSgfRcxxG2k4I9n3O9NRqy8U+uugaT2Dy7kLO9Y7wI72haOahxceMX8hZAzgGou1FhndRldxRg=="], 113 | 114 | "@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.4", "", { "os": "win32", "cpu": "x64" }, "sha512-nOT2vZNw6hJ+z43oP1SPea/G/6AbN6X+bGNhNuq8NtRHy4wsMhw765IKLNmnjek7GvjWBYQ8Q5VBoYTFg9y1UQ=="], 115 | 116 | "@gql.tada/cli-utils": ["@gql.tada/cli-utils@1.7.2", "", { "dependencies": { "@0no-co/graphqlsp": "^1.12.13", "@gql.tada/internal": "1.0.8", "graphql": "^15.5.0 || ^16.0.0 || ^17.0.0" }, "peerDependencies": { "@gql.tada/svelte-support": "1.0.1", "@gql.tada/vue-support": "1.0.1", "typescript": "^5.0.0" }, "optionalPeers": ["@gql.tada/svelte-support", "@gql.tada/vue-support"] }, "sha512-Qbc7hbLvCz6IliIJpJuKJa9p05b2Jona7ov7+qofCsMRxHRZE1kpAmZMvL8JCI4c0IagpIlWNaMizXEQUe8XjQ=="], 117 | 118 | "@gql.tada/internal": ["@gql.tada/internal@1.0.8", "", { "dependencies": { "@0no-co/graphql.web": "^1.0.5" }, "peerDependencies": { "graphql": "^15.5.0 || ^16.0.0 || ^17.0.0", "typescript": "^5.0.0" } }, "sha512-XYdxJhtHC5WtZfdDqtKjcQ4d7R1s0d1rnlSs3OcBEUbYiPoJJfZU7tWsVXuv047Z6msvmr4ompJ7eLSK5Km57g=="], 119 | 120 | "@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.0.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ=="], 121 | 122 | "@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.0.4" }, "os": "darwin", "cpu": "x64" }, "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q=="], 123 | 124 | "@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.0.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg=="], 125 | 126 | "@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.0.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ=="], 127 | 128 | "@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.0.5", "", { "os": "linux", "cpu": "arm" }, "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g=="], 129 | 130 | "@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.0.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA=="], 131 | 132 | "@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.0.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA=="], 133 | 134 | "@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.0.4", "", { "os": "linux", "cpu": "x64" }, "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw=="], 135 | 136 | "@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.0.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA=="], 137 | 138 | "@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.0.4", "", { "os": "linux", "cpu": "x64" }, "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw=="], 139 | 140 | "@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.0.5" }, "os": "linux", "cpu": "arm" }, "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ=="], 141 | 142 | "@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.0.4" }, "os": "linux", "cpu": "arm64" }, "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA=="], 143 | 144 | "@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.0.4" }, "os": "linux", "cpu": "s390x" }, "sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q=="], 145 | 146 | "@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.0.4" }, "os": "linux", "cpu": "x64" }, "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA=="], 147 | 148 | "@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.0.4" }, "os": "linux", "cpu": "arm64" }, "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g=="], 149 | 150 | "@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.0.4" }, "os": "linux", "cpu": "x64" }, "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw=="], 151 | 152 | "@img/sharp-wasm32": ["@img/sharp-wasm32@0.33.5", "", { "dependencies": { "@emnapi/runtime": "^1.2.0" }, "cpu": "none" }, "sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg=="], 153 | 154 | "@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.33.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ=="], 155 | 156 | "@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.33.5", "", { "os": "win32", "cpu": "x64" }, "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg=="], 157 | 158 | "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], 159 | 160 | "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], 161 | 162 | "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.9", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.0.3", "@jridgewell/sourcemap-codec": "^1.4.10" } }, "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ=="], 163 | 164 | "@poppinss/colors": ["@poppinss/colors@4.1.5", "", { "dependencies": { "kleur": "^4.1.5" } }, "sha512-FvdDqtcRCtz6hThExcFOgW0cWX+xwSMWcRuQe5ZEb2m7cVQOAVZOIMt+/v9RxGiD9/OY16qJBXK4CVKWAPalBw=="], 165 | 166 | "@poppinss/dumper": ["@poppinss/dumper@0.6.5", "", { "dependencies": { "@poppinss/colors": "^4.1.5", "@sindresorhus/is": "^7.0.2", "supports-color": "^10.0.0" } }, "sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw=="], 167 | 168 | "@poppinss/exception": ["@poppinss/exception@1.2.2", "", {}, "sha512-m7bpKCD4QMlFCjA/nKTs23fuvoVFoA83brRKmObCUNmi/9tVu8Ve3w4YQAnJu4q3Tjf5fr685HYIC/IA2zHRSg=="], 169 | 170 | "@sindresorhus/is": ["@sindresorhus/is@7.1.1", "", {}, "sha512-rO92VvpgMc3kfiTjGT52LEtJ8Yc5kCWhZjLQ3LwlA4pSgPpQO7bVpYXParOD8Jwf+cVQECJo3yP/4I8aZtUQTQ=="], 171 | 172 | "@speed-highlight/core": ["@speed-highlight/core@1.2.12", "", {}, "sha512-uilwrK0Ygyri5dToHYdZSjcvpS2ZwX0w5aSt3GCEN9hrjxWCoeV4Z2DTXuxjwbntaLQIEEAlCeNQss5SoHvAEA=="], 173 | 174 | "@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="], 175 | 176 | "@types/node-fetch": ["@types/node-fetch@2.6.13", "", { "dependencies": { "@types/node": "*", "form-data": "^4.0.4" } }, "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw=="], 177 | 178 | "@urql/core": ["@urql/core@6.0.1", "", { "dependencies": { "@0no-co/graphql.web": "^1.0.13", "wonka": "^6.3.2" } }, "sha512-FZDiQk6jxbj5hixf2rEPv0jI+IZz0EqqGW8mJBEug68/zHTtT+f34guZDmyjJZyiWbj0vL165LoMr/TkeDHaug=="], 179 | 180 | "abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="], 181 | 182 | "acorn": ["acorn@8.14.0", "", { "bin": "bin/acorn" }, "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA=="], 183 | 184 | "acorn-walk": ["acorn-walk@8.3.2", "", {}, "sha512-cjkyv4OtNCIeqhHrfS81QWXoCBPExR/J62oyEqepVw8WaQeSqpW2uhuLPh1m9eWhDuOo/jUXVTlifvesOWp/4A=="], 185 | 186 | "agentkeepalive": ["agentkeepalive@4.6.0", "", { "dependencies": { "humanize-ms": "^1.2.1" } }, "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ=="], 187 | 188 | "asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="], 189 | 190 | "blake3-wasm": ["blake3-wasm@2.1.5", "", {}, "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g=="], 191 | 192 | "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], 193 | 194 | "cloudflare": ["cloudflare@5.2.0", "", { "dependencies": { "@types/node": "^18.11.18", "@types/node-fetch": "^2.6.4", "abort-controller": "^3.0.0", "agentkeepalive": "^4.2.1", "form-data-encoder": "1.7.2", "formdata-node": "^4.3.2", "node-fetch": "^2.6.7" } }, "sha512-dVzqDpPFYR9ApEC9e+JJshFJZXcw4HzM8W+3DHzO5oy9+8rLC53G7x6fEf9A7/gSuSCxuvndzui5qJKftfIM9A=="], 195 | 196 | "color": ["color@4.2.3", "", { "dependencies": { "color-convert": "^2.0.1", "color-string": "^1.9.0" } }, "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A=="], 197 | 198 | "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], 199 | 200 | "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], 201 | 202 | "color-string": ["color-string@1.9.1", "", { "dependencies": { "color-name": "^1.0.0", "simple-swizzle": "^0.2.2" } }, "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg=="], 203 | 204 | "combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="], 205 | 206 | "consola": ["consola@3.4.2", "", {}, "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA=="], 207 | 208 | "cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="], 209 | 210 | "dataloader": ["dataloader@2.2.3", "", {}, "sha512-y2krtASINtPFS1rSDjacrFgn1dcUuoREVabwlOGOe4SdxenREqwjwjElAdwvbGM7kgZz9a3KVicWR7vcz8rnzA=="], 211 | 212 | "delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="], 213 | 214 | "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], 215 | 216 | "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], 217 | 218 | "error-stack-parser-es": ["error-stack-parser-es@1.0.5", "", {}, "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA=="], 219 | 220 | "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], 221 | 222 | "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], 223 | 224 | "es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="], 225 | 226 | "es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="], 227 | 228 | "esbuild": ["esbuild@0.25.4", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.4", "@esbuild/android-arm": "0.25.4", "@esbuild/android-arm64": "0.25.4", "@esbuild/android-x64": "0.25.4", "@esbuild/darwin-arm64": "0.25.4", "@esbuild/darwin-x64": "0.25.4", "@esbuild/freebsd-arm64": "0.25.4", "@esbuild/freebsd-x64": "0.25.4", "@esbuild/linux-arm": "0.25.4", "@esbuild/linux-arm64": "0.25.4", "@esbuild/linux-ia32": "0.25.4", "@esbuild/linux-loong64": "0.25.4", "@esbuild/linux-mips64el": "0.25.4", "@esbuild/linux-ppc64": "0.25.4", "@esbuild/linux-riscv64": "0.25.4", "@esbuild/linux-s390x": "0.25.4", "@esbuild/linux-x64": "0.25.4", "@esbuild/netbsd-arm64": "0.25.4", "@esbuild/netbsd-x64": "0.25.4", "@esbuild/openbsd-arm64": "0.25.4", "@esbuild/openbsd-x64": "0.25.4", "@esbuild/sunos-x64": "0.25.4", "@esbuild/win32-arm64": "0.25.4", "@esbuild/win32-ia32": "0.25.4", "@esbuild/win32-x64": "0.25.4" }, "bin": "bin/esbuild" }, "sha512-8pgjLUcUjcgDg+2Q4NYXnPbo/vncAY4UmyaCm0jZevERqCHZIaWwdJHkf8XQtu4AxSKCdvrUbT0XUr1IdZzI8Q=="], 229 | 230 | "event-target-shim": ["event-target-shim@5.0.1", "", {}, "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ=="], 231 | 232 | "exit-hook": ["exit-hook@2.2.1", "", {}, "sha512-eNTPlAD67BmP31LDINZ3U7HSF8l57TxOY2PmBJ1shpCvpnxBF93mWCE8YHBnXs8qiUZJc9WDcWIeC3a2HIAMfw=="], 233 | 234 | "form-data": ["form-data@4.0.5", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w=="], 235 | 236 | "form-data-encoder": ["form-data-encoder@1.7.2", "", {}, "sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A=="], 237 | 238 | "formdata-node": ["formdata-node@4.4.1", "", { "dependencies": { "node-domexception": "1.0.0", "web-streams-polyfill": "4.0.0-beta.3" } }, "sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ=="], 239 | 240 | "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], 241 | 242 | "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], 243 | 244 | "get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="], 245 | 246 | "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], 247 | 248 | "glob-to-regexp": ["glob-to-regexp@0.4.1", "", {}, "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw=="], 249 | 250 | "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], 251 | 252 | "gql.tada": ["gql.tada@1.9.0", "", { "dependencies": { "@0no-co/graphql.web": "^1.0.5", "@0no-co/graphqlsp": "^1.12.13", "@gql.tada/cli-utils": "1.7.2", "@gql.tada/internal": "1.0.8" }, "peerDependencies": { "typescript": "^5.0.0" }, "bin": { "gql.tada": "bin/cli.js", "gql-tada": "bin/cli.js" } }, "sha512-1LMiA46dRs5oF7Qev6vMU32gmiNvM3+3nHoQZA9K9j2xQzH8xOAWnnJrLSbZOFHTSdFxqn86TL6beo1/7ja/aA=="], 253 | 254 | "graphql": ["graphql@16.12.0", "", {}, "sha512-DKKrynuQRne0PNpEbzuEdHlYOMksHSUI8Zc9Unei5gTsMNA2/vMpoMz/yKba50pejK56qj98qM0SjYxAKi13gQ=="], 255 | 256 | "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], 257 | 258 | "has-tostringtag": ["has-tostringtag@1.0.2", "", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="], 259 | 260 | "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], 261 | 262 | "hono": ["hono@4.10.8", "", {}, "sha512-DDT0A0r6wzhe8zCGoYOmMeuGu3dyTAE40HHjwUsWFTEy5WxK1x2WDSsBPlEXgPbRIFY6miDualuUDbasPogIww=="], 263 | 264 | "humanize-ms": ["humanize-ms@1.2.1", "", { "dependencies": { "ms": "^2.0.0" } }, "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ=="], 265 | 266 | "install": ["install@0.13.0", "", {}, "sha512-zDml/jzr2PKU9I8J/xyZBQn8rPCAY//UOYNmR01XwNwyfhEWObo2SWfSl1+0tm1u6PhxLwDnfsT/6jB7OUxqFA=="], 267 | 268 | "is-arrayish": ["is-arrayish@0.3.4", "", {}, "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA=="], 269 | 270 | "kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="], 271 | 272 | "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], 273 | 274 | "mime": ["mime@3.0.0", "", { "bin": "cli.js" }, "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A=="], 275 | 276 | "mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], 277 | 278 | "mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], 279 | 280 | "miniflare": ["miniflare@4.20251125.0", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "acorn": "8.14.0", "acorn-walk": "8.3.2", "exit-hook": "2.2.1", "glob-to-regexp": "0.4.1", "sharp": "^0.33.5", "stoppable": "1.1.0", "undici": "7.14.0", "workerd": "1.20251125.0", "ws": "8.18.0", "youch": "4.1.0-beta.10", "zod": "3.22.3" }, "bin": "bootstrap.js" }, "sha512-xY6deLx0Drt8GfGG2Fv0fHUocHAIG/Iv62Kl36TPfDzgq7/+DQ5gYNisxnmyISQdA/sm7kOvn2XRBncxjWYrLg=="], 281 | 282 | "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], 283 | 284 | "node-domexception": ["node-domexception@1.0.0", "", {}, "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ=="], 285 | 286 | "node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="], 287 | 288 | "path-to-regexp": ["path-to-regexp@6.3.0", "", {}, "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ=="], 289 | 290 | "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], 291 | 292 | "semver": ["semver@7.7.3", "", { "bin": "bin/semver.js" }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], 293 | 294 | "sharp": ["sharp@0.33.5", "", { "dependencies": { "color": "^4.2.3", "detect-libc": "^2.0.3", "semver": "^7.6.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.33.5", "@img/sharp-darwin-x64": "0.33.5", "@img/sharp-libvips-darwin-arm64": "1.0.4", "@img/sharp-libvips-darwin-x64": "1.0.4", "@img/sharp-libvips-linux-arm": "1.0.5", "@img/sharp-libvips-linux-arm64": "1.0.4", "@img/sharp-libvips-linux-s390x": "1.0.4", "@img/sharp-libvips-linux-x64": "1.0.4", "@img/sharp-libvips-linuxmusl-arm64": "1.0.4", "@img/sharp-libvips-linuxmusl-x64": "1.0.4", "@img/sharp-linux-arm": "0.33.5", "@img/sharp-linux-arm64": "0.33.5", "@img/sharp-linux-s390x": "0.33.5", "@img/sharp-linux-x64": "0.33.5", "@img/sharp-linuxmusl-arm64": "0.33.5", "@img/sharp-linuxmusl-x64": "0.33.5", "@img/sharp-wasm32": "0.33.5", "@img/sharp-win32-ia32": "0.33.5", "@img/sharp-win32-x64": "0.33.5" } }, "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw=="], 295 | 296 | "simple-swizzle": ["simple-swizzle@0.2.4", "", { "dependencies": { "is-arrayish": "^0.3.1" } }, "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw=="], 297 | 298 | "stoppable": ["stoppable@1.1.0", "", {}, "sha512-KXDYZ9dszj6bzvnEMRYvxgeTHU74QBFL54XKtP3nyMuJ81CFYtABZ3bAzL2EdFUaEwJOBOgENyFj3R7oTzDyyw=="], 299 | 300 | "supports-color": ["supports-color@10.2.2", "", {}, "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g=="], 301 | 302 | "tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="], 303 | 304 | "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], 305 | 306 | "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], 307 | 308 | "undici": ["undici@7.14.0", "", {}, "sha512-Vqs8HTzjpQXZeXdpsfChQTlafcMQaaIwnGwLam1wudSSjlJeQ3bw1j+TLPePgrCnCpUXx7Ba5Pdpf5OBih62NQ=="], 309 | 310 | "undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="], 311 | 312 | "unenv": ["unenv@2.0.0-rc.24", "", { "dependencies": { "pathe": "^2.0.3" } }, "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw=="], 313 | 314 | "web-streams-polyfill": ["web-streams-polyfill@4.0.0-beta.3", "", {}, "sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug=="], 315 | 316 | "webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="], 317 | 318 | "whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="], 319 | 320 | "wonka": ["wonka@6.3.5", "", {}, "sha512-SSil+ecw6B4/Dm7Pf2sAshKQ5hWFvfyGlfPbEd6A14dOH6VDjrmbY86u6nZvy9omGwwIPFR8V41+of1EezgoUw=="], 321 | 322 | "workerd": ["workerd@1.20251125.0", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20251125.0", "@cloudflare/workerd-darwin-arm64": "1.20251125.0", "@cloudflare/workerd-linux-64": "1.20251125.0", "@cloudflare/workerd-linux-arm64": "1.20251125.0", "@cloudflare/workerd-windows-64": "1.20251125.0" }, "bin": "bin/workerd" }, "sha512-oQYfgu3UZ15HlMcEyilKD1RdielRnKSG5MA0xoi1theVs99Rop9AEFYicYCyK1R4YjYblLRYEiL1tMgEFqpReA=="], 323 | 324 | "wrangler": ["wrangler@4.51.0", "", { "dependencies": { "@cloudflare/kv-asset-handler": "0.4.1", "@cloudflare/unenv-preset": "2.7.11", "blake3-wasm": "2.1.5", "esbuild": "0.25.4", "miniflare": "4.20251125.0", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.24", "workerd": "1.20251125.0" }, "optionalDependencies": { "fsevents": "~2.3.2" }, "peerDependencies": { "@cloudflare/workers-types": "^4.20251125.0" }, "optionalPeers": ["@cloudflare/workers-types"], "bin": { "wrangler": "bin/wrangler.js", "wrangler2": "bin/wrangler.js" } }, "sha512-JHv+58UxM2//e4kf9ASDwg016xd/OdDNDUKW6zLQyE7Uc9ayYKX1QJ9NsYtpo4dC1dfg6rT67pf1aNK1cTzUDg=="], 325 | 326 | "ws": ["ws@8.18.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw=="], 327 | 328 | "youch": ["youch@4.1.0-beta.10", "", { "dependencies": { "@poppinss/colors": "^4.1.5", "@poppinss/dumper": "^0.6.4", "@speed-highlight/core": "^1.2.7", "cookie": "^1.0.2", "youch-core": "^0.3.3" } }, "sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ=="], 329 | 330 | "youch-core": ["youch-core@0.3.3", "", { "dependencies": { "@poppinss/exception": "^1.2.2", "error-stack-parser-es": "^1.0.5" } }, "sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA=="], 331 | 332 | "zod": ["zod@4.1.13", "", {}, "sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig=="], 333 | 334 | "miniflare/zod": ["zod@3.22.3", "", {}, "sha512-EjIevzuJRiRPbVH4mGc8nApb/lVLKVpmUhAaR5R5doKGfAnGJ6Gr3CViAVjP+4FWSxCsybeWQdcgCtbX+7oZug=="], 335 | } 336 | } 337 | --------------------------------------------------------------------------------