Cloudflare Prometheus Exporter

This commit is contained in:
Dillon Mulroy
2025-12-07 22:38:22 -05:00
committed by dmmulroy
parent 8cb54fc243
commit e64d93a520
39 changed files with 179753 additions and 1699 deletions

View File

@@ -1,12 +0,0 @@
# http://editorconfig.org
root = true
[*]
indent_style = tab
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
[*.yml]
indent_style = space

7
.gitignore vendored
View File

@@ -165,3 +165,10 @@ dist
.env*
!.env.example
.wrangler/
# claude
.claude/
# playwright mcp
.playwright-mcp/

View File

@@ -1,6 +0,0 @@
{
"printWidth": 140,
"singleQuote": true,
"semi": true,
"useTabs": true
}

View File

@@ -1,5 +0,0 @@
{
"files.associations": {
"wrangler.json": "jsonc"
}
}

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

536
README.md Normal file
View File

@@ -0,0 +1,536 @@
# Cloudflare Prometheus Exporter
[![Cloudflare Prometheus Exporter](https://github.com/user-attachments/assets/33794cd1-f03d-4382-9bb6-83d77cd01de5)](https://github.com/cloudflare/cloudflare-prometheus-exporter)
Export Cloudflare metrics to Prometheus. Built on Cloudflare Workers with Durable Objects for stateful metric accumulation.
[![Deploy to Cloudflare Workers](https://deploy.workers.cloudflare.com/button)](https://deploy.workers.cloudflare.com/?url=https://github.com/cloudflare/cloudflare-prometheus-exporter)
## Features
- **58 Prometheus metrics** - requests, bandwidth, threats, workers, load balancers, SSL certs, and more
- **Cloudflare Workers** - serverless edge deployment
- **Durable Objects** - stateful counter accumulation for proper Prometheus semantics
- **Background refresh** - alarms fetch data every 60s; scrapes return cached data instantly
- **Rate limiting** - 40 req/10s with exponential backoff
- **Multi-account** - automatically discovers and exports all accessible accounts/zones
- **Runtime config API** - change settings without redeployment via REST endpoints
- **Configurable** - zone filtering, metric denylist, label exclusion, custom metrics path, and more
## Quick Start
### One-Click Deploy
Click the deploy button above. Configure `CLOUDFLARE_API_TOKEN` as a secret after deployment.
### Manual Deployment
```bash
git clone https://github.com/cloudflare/cloudflare-prometheus-exporter.git
cd cloudflare-prometheus-exporter
bun install
wrangler secret put CLOUDFLARE_API_TOKEN
bun run deploy
```
## Configuration
Configuration is resolved in order: **KV overrides****env vars****defaults**. Use the [Runtime Config API](#runtime-config-api) for dynamic changes without redeployment.
### Environment Variables
Set in `wrangler.jsonc` or via `wrangler secret put`:
| Variable | Default | Description |
|----------|---------|-------------|
| `CLOUDFLARE_API_TOKEN` | - | Cloudflare API token (secret) |
| `QUERY_LIMIT` | 10000 | Max results per GraphQL query |
| `SCRAPE_DELAY_SECONDS` | 300 | Delay before fetching metrics (data propagation) |
| `TIME_WINDOW_SECONDS` | 60 | Query time window |
| `METRIC_REFRESH_INTERVAL_SECONDS` | 60 | Background refresh interval |
| `LOG_LEVEL` | info | Log level (debug/info/warn/error) |
| `LOG_FORMAT` | json | Log format (pretty/json) |
| `ACCOUNT_LIST_CACHE_TTL_SECONDS` | 600 | Account list cache TTL |
| `ZONE_LIST_CACHE_TTL_SECONDS` | 1800 | Zone list cache TTL |
| `SSL_CERTS_CACHE_TTL_SECONDS` | 1800 | SSL cert cache TTL |
| `HEALTH_CHECK_CACHE_TTL_SECONDS` | 10 | Health check cache TTL |
| `EXCLUDE_HOST` | false | Exclude host labels from metrics |
| `CF_HTTP_STATUS_GROUP` | false | Group HTTP status codes (2xx, 4xx, etc.) |
| `DISABLE_UI` | false | Disable landing page (returns 404) |
| `DISABLE_CONFIG_API` | false | Disable config API endpoints (returns 404) |
| `METRICS_DENYLIST` | - | Comma-separated list of metrics to exclude |
| `CF_ACCOUNTS` | - | Comma-separated account IDs to include (default: all) |
| `CF_ZONES` | - | Comma-separated zone IDs to include (default: all) |
| `CF_FREE_TIER_ACCOUNTS` | - | Comma-separated account IDs using free tier (skips paid-tier metrics) |
| `METRICS_PATH` | /metrics | Custom path for metrics endpoint |
### Creating an API Token
**Quick setup**: [Create token with pre-filled permissions](https://dash.cloudflare.com/profile/api-tokens?permissionGroupKeys=%5B%7B%22key%22%3A%22zone_analytics%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_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%22logpush%22%2C%22type%22%3A%22read%22%7D%5D&name=Cloudflare%20Prometheus%20Exporter)
**Manual setup**:
| Permission | Access | Required |
|------------|--------|----------|
| Zone > Analytics | Read | Yes |
| Account > Account Analytics | Read | Yes |
| Account > Workers Scripts | Read | Yes |
| Zone > SSL and Certificates | Read | Optional |
| Zone > Firewall Services | Read | Optional |
| Zone > Load Balancers | Read | Optional |
| Account > Logpush | Read | Optional |
| Account > Magic Transit | Read | Optional |
## Endpoints
| Path | Method | Description |
|------|--------|-------------|
| `/` | GET | Landing page (disable: `DISABLE_UI`) |
| `/metrics` | GET | Prometheus metrics |
| `/health` | GET | Health check (`{"status":"healthy"}`) |
| `/config` | GET | Get all runtime config (disable: `DISABLE_CONFIG_API`) |
| `/config` | DELETE | Reset all config to env defaults (disable: `DISABLE_CONFIG_API`) |
| `/config/:key` | GET | Get single config value (disable: `DISABLE_CONFIG_API`) |
| `/config/:key` | PUT | Set config override (persisted in KV) (disable: `DISABLE_CONFIG_API`) |
| `/config/:key` | DELETE | Reset config key to env default (disable: `DISABLE_CONFIG_API`) |
## Prometheus Configuration
```yaml
scrape_configs:
- job_name: 'cloudflare'
scrape_interval: 60s
scrape_timeout: 30s
static_configs:
- targets: ['your-worker.your-subdomain.workers.dev']
```
## Runtime Config API
Override configuration at runtime without redeployment. Overrides persist in KV and take precedence over `wrangler.jsonc` env vars.
### Config Keys
| Key | Type | Description |
|-----|------|-------------|
| `queryLimit` | number | Max results per GraphQL query |
| `scrapeDelaySeconds` | number | Delay before fetching metrics |
| `timeWindowSeconds` | number | Query time window |
| `metricRefreshIntervalSeconds` | number | Background refresh interval |
| `accountListCacheTtlSeconds` | number | Account list cache TTL |
| `zoneListCacheTtlSeconds` | number | Zone list cache TTL |
| `sslCertsCacheTtlSeconds` | number | SSL cert cache TTL |
| `healthCheckCacheTtlSeconds` | number | Health check cache TTL |
| `logFormat` | `"json"` \| `"pretty"` | Log format |
| `logLevel` | `"debug"` \| `"info"` \| `"warn"` \| `"error"` | Log level |
| `cfAccounts` | string \| null | Comma-separated account IDs (null = all) |
| `cfZones` | string \| null | Comma-separated zone IDs (null = all) |
| `cfFreeTierAccounts` | string | Comma-separated free tier account IDs |
| `metricsDenylist` | string | Comma-separated metrics to exclude |
| `excludeHost` | boolean | Exclude host labels |
| `httpStatusGroup` | boolean | Group HTTP status codes |
### Examples
```bash
# Get all config
curl https://your-worker.workers.dev/config
# Get single value
curl https://your-worker.workers.dev/config/logLevel
# Set override
curl -X PUT https://your-worker.workers.dev/config/logLevel \
-H "Content-Type: application/json" \
-d '{"value": "debug"}'
# Filter to specific zones
curl -X PUT https://your-worker.workers.dev/config/cfZones \
-H "Content-Type: application/json" \
-d '{"value": "zone-id-1,zone-id-2"}'
# Reset to env default
curl -X DELETE https://your-worker.workers.dev/config/logLevel
# Reset all overrides
curl -X DELETE https://your-worker.workers.dev/config
```
## Available Metrics
### Zone Request Metrics
| Metric | Type | Labels |
|--------|------|--------|
| `cloudflare_zone_requests_total` | counter | zone |
| `cloudflare_zone_requests_cached` | gauge | zone |
| `cloudflare_zone_requests_ssl_encrypted` | counter | zone |
| `cloudflare_zone_requests_content_type` | counter | zone, content_type |
| `cloudflare_zone_requests_country` | counter | zone, country, region |
| `cloudflare_zone_requests_status` | counter | zone, status |
| `cloudflare_zone_requests_browser_map_page_views_count` | counter | zone, family |
| `cloudflare_zone_requests_ip_class` | counter | zone, ip_class |
| `cloudflare_zone_requests_ssl_protocol` | counter | zone, ssl_protocol |
| `cloudflare_zone_requests_http_version` | counter | zone, http_version |
| `cloudflare_zone_requests_origin_status_country_host` | counter | zone, origin_status, country, host |
| `cloudflare_zone_requests_status_country_host` | counter | zone, edge_status, country, host |
| `cloudflare_zone_request_method_count` | counter | zone, method |
### Zone Bandwidth Metrics
| Metric | Type | Labels |
|--------|------|--------|
| `cloudflare_zone_bandwidth_total` | counter | zone |
| `cloudflare_zone_bandwidth_cached` | counter | zone |
| `cloudflare_zone_bandwidth_ssl_encrypted` | counter | zone |
| `cloudflare_zone_bandwidth_content_type` | counter | zone, content_type |
| `cloudflare_zone_bandwidth_country` | counter | zone, country |
### Zone Threat Metrics
| Metric | Type | Labels |
|--------|------|--------|
| `cloudflare_zone_threats_total` | counter | zone |
| `cloudflare_zone_threats_country` | counter | zone, country |
| `cloudflare_zone_threats_type` | counter | zone, type |
### Zone Page/Unique Metrics
| Metric | Type | Labels |
|--------|------|--------|
| `cloudflare_zone_pageviews_total` | counter | zone |
| `cloudflare_zone_uniques_total` | counter | zone |
### Colocation Metrics
| Metric | Type | Labels |
|--------|------|--------|
| `cloudflare_zone_colocation_visits` | counter | zone, colo, host |
| `cloudflare_zone_colocation_edge_response_bytes` | counter | zone, colo, host |
| `cloudflare_zone_colocation_requests_total` | counter | zone, colo, host |
| `cloudflare_zone_colocation_visits_error` | counter | zone, colo, host, status |
| `cloudflare_zone_colocation_edge_response_bytes_error` | counter | zone, colo, host, status |
| `cloudflare_zone_colocation_requests_total_error` | counter | zone, colo, host, status |
### Firewall Metrics
| Metric | Type | Labels |
|--------|------|--------|
| `cloudflare_zone_firewall_events_count` | counter | zone, action, source, rule, host, country |
| `cloudflare_zone_firewall_bots_detected` | counter | zone, bot_score, detection_ids |
### Health Check Metrics
| Metric | Type | Labels |
|--------|------|--------|
| `cloudflare_zone_health_check_events_origin_count` | counter | zone, health_status, origin_ip, region, fqdn, failure_reason |
| `cloudflare_zone_health_check_events_avg` | gauge | zone |
| `cloudflare_zone_health_check_rtt_ms` | gauge | zone, origin_ip, fqdn |
| `cloudflare_zone_health_check_ttfb_ms` | gauge | zone, origin_ip, fqdn |
| `cloudflare_zone_health_check_tcp_conn_ms` | gauge | zone, origin_ip, fqdn |
| `cloudflare_zone_health_check_tls_handshake_ms` | gauge | zone, origin_ip, fqdn |
### Worker Metrics
| Metric | Type | Labels |
|--------|------|--------|
| `cloudflare_worker_requests_count` | counter | script_name |
| `cloudflare_worker_errors_count` | counter | script_name |
| `cloudflare_worker_cpu_time` | gauge | script_name, quantile |
| `cloudflare_worker_duration` | gauge | script_name, quantile |
### Load Balancer Metrics
| Metric | Type | Labels |
|--------|------|--------|
| `cloudflare_zone_pool_health_status` | gauge | zone, lb_name, pool_name |
| `cloudflare_zone_pool_requests_total` | counter | zone, lb_name, pool_name, origin_name |
| `cloudflare_zone_lb_pool_rtt_ms` | gauge | zone, lb_name, pool_name |
| `cloudflare_zone_lb_steering_policy_info` | gauge | zone, lb_name, policy |
| `cloudflare_zone_lb_origins_selected_count` | gauge | zone, lb_name, pool_name |
| `cloudflare_zone_lb_origin_weight` | gauge | zone, lb_name, pool_name, origin_name |
### Logpush Metrics
| Metric | Type | Labels |
|--------|------|--------|
| `cloudflare_logpush_failed_jobs_account_count` | counter | account, job_id, destination_type |
| `cloudflare_logpush_failed_jobs_zone_count` | counter | zone, job_id, destination_type |
### Error Rate Metrics
| Metric | Type | Labels |
|--------|------|--------|
| `cloudflare_zone_customer_error_4xx_rate` | counter | zone, status, country, host |
| `cloudflare_zone_customer_error_5xx_rate` | counter | zone, status, country, host |
| `cloudflare_zone_edge_error_rate` | gauge | zone, status |
| `cloudflare_zone_origin_error_rate` | gauge | zone, status |
| `cloudflare_zone_origin_response_duration_ms` | gauge | zone, status, country, host |
### Cache Metrics
| Metric | Type | Labels |
|--------|------|--------|
| `cloudflare_zone_cache_hit_ratio` | gauge | zone |
| `cloudflare_zone_cache_miss_origin_duration_ms` | gauge | zone, country, host |
### Bot Metrics
| Metric | Type | Labels |
|--------|------|--------|
| `cloudflare_zone_bot_request_by_country` | counter | zone, country |
### Magic Transit Metrics
| Metric | Type | Labels |
|--------|------|--------|
| `cloudflare_magic_transit_active_tunnels` | gauge | account |
| `cloudflare_magic_transit_healthy_tunnels` | gauge | account |
| `cloudflare_magic_transit_tunnel_failures` | gauge | account |
| `cloudflare_magic_transit_edge_colo_count` | gauge | account |
### SSL Certificate Metrics
| Metric | Type | Labels |
|--------|------|--------|
| `cloudflare_zone_certificate_validation_status` | gauge | zone, type, issuer, status |
### Exporter Info Metrics
| Metric | Type | Labels |
|--------|------|--------|
| `cloudflare_exporter_up` | gauge | - |
| `cloudflare_exporter_errors_total` | counter | account_id, error_code |
| `cloudflare_accounts_total` | gauge | - |
| `cloudflare_zones_total` | gauge | - |
| `cloudflare_zones_filtered` | gauge | - |
| `cloudflare_zones_processed` | gauge | - |
## Architecture
```
┌────────────────────────────────────────────────────────────────────────────────┐
│ WORKER ISOLATE │
│ ┌────────────────┐ │
│ │ Worker.fetch │◄─── HTTP /metrics, /health, /config │
│ │ (HTTP handler) │ │
│ └───────┬────────┘ │
│ │ │
│ │ RPC (stub.export()) │
│ ▼ │
│ ┌────────────────────────────────────────────────────────────────────────┐ │
│ │ CONFIG_KV: Runtime config overrides (merged with env defaults) │ │
│ └────────────────────────────────────────────────────────────────────────┘ │
└──────────┼─────────────────────────────────────────────────────────────────────┘
┌────────────────────────────────────────────────────────────────────────────────┐
│ DURABLE OBJECT ISOLATES │
│ │
│ Each DO runs in its own V8 isolate with: │
│ - Own CloudflareMetricsClient instance (per-isolate singleton) │
│ - Own persistent storage │
│ - Own alarm scheduler │
│ │
│ ┌─────────────────────────────────────────────────────────────────────────┐ │
│ │ MetricCoordinator (1 global instance) │ │
│ │ ID: "metric-coordinator" │ │
│ │ State: accounts[], lastAccountFetch │ │
│ │ Cache TTL: 600s (account list) │ │
│ └─────────────────────────────────────────────────────────────────────────┘ │
│ │ RPC │
│ ┌────────────┼────────────┐ │
│ ▼ ▼ ▼ │
│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │
│ │ AccountMetric │ │ AccountMetric │ │ AccountMetric │ │
│ │ Coordinator │ │ Coordinator │ │ Coordinator │ │
│ │ account:acct1 │ │ account:acct2 │ │ account:acct3 │ │
│ │ Alarm: 60s │ │ Alarm: 60s │ │ Alarm: 60s │ │
│ │ Zone TTL: 1800s │ │ Zone TTL: 1800s │ │ Zone TTL: 1800s │ │
│ └───────┬─────────┘ └───────┬─────────┘ └───────┬─────────┘ │
│ │ RPC │ │ │
│ ┌──────┴─────┐ ┌──────┴─────┐ ┌──────┴─────┐ │
│ ▼ ▼ ▼ ▼ ▼ ▼ │
│ ┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐ │
│ │Exprt│ │Exprt│ │Exprt│ │Exprt│ │Exprt│ │Exprt│ │
│ │(13) │ .. │(N) │ │(13) │ .. │(N) │ │(13) │ .. │(N) │ │
│ │acct │ │zone │ │acct │ │zone │ │acct │ │zone │ │
│ └─────┘ └─────┘ └─────┘ └─────┘ └─────┘ └─────┘ │
│ │
│ MetricExporter DOs (per account): │
│ - Account-scoped (13): worker-totals, logpush-account, magic-transit, │
│ http-metrics, adaptive-metrics, edge-country-metrics, colo-metrics, │
│ colo-error-metrics, request-method-metrics, health-check-metrics, │
│ load-balancer-metrics, logpush-zone, origin-status-metrics │
│ - Zone-scoped (N per account, 1 per zone): ssl-certificates │
│ │
│ ┌─────────────────────────────────────────────────────────────────────────┐ │
│ │ CloudflareMetricsClient (per-isolate) │ │
│ │ - urql Client (GraphQL) │ │
│ │ - Cloudflare SDK (REST) │ │
│ │ - DataLoader: firewallRulesLoader (batches Promise.all calls) │ │
│ │ - Global Rate limiter: 40 req/10s with exponential backoff │ │
│ └─────────────────────────────────────────────────────────────────────────┘ │
└────────────────────────────────────────────────────────────────────────────────┘
```
### Request Path: Prometheus Scrape (GET /metrics)
```
┌──────────┐ GET /metrics ┌────────┐
│Prometheus│────────────────▶│ Worker │
│ Server │ │ .fetch │
└──────────┘ └───┬────┘
┌──────────────────────┴──────────────────────┐
│ MetricCoordinator │
│ │
│ 1. Check account cache (TTL: 600s) │
│ 2. If stale → getAccounts() │
│ 3. Fan out to AccountMetricCoordinators │
└─────────────────────┬───────────────────────┘
┌────────────────────────┼────────────────────────┐
│ │ │
▼ ▼ ▼
┌────────────────┐ ┌────────────────┐ ┌────────────────┐
│ AccountMetric │ │ AccountMetric │ │ AccountMetric │
│ Coordinator │ │ Coordinator │ │ Coordinator │
│ (Account A) │ │ (Account B) │ │ (Account C) │
│ │ │ │ │ │
│ 1. Check if │ │ │ │ │
│ refresh() │ │ (parallel) │ │ (parallel) │
│ needed │ │ │ │ │
│ 2. Fan out to │ │ │ │ │
│ exporters │ │ │ │ │
└───────┬────────┘ └───────┬────────┘ └───────┬────────┘
│ │ │
┌─────┴─────┐ ┌─────┴─────┐ ┌─────┴─────┐
▼ ▼ ▼ ▼ ▼ ▼
┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐
│Exprt│...│Exprt│ │Exprt│...│Exprt│ │Exprt│...│Exprt│
│13+N │ │ │ │13+N │ │ │ │13+N │ │ │
│ │ │ │ │ │ │ │ │ │ │ │
│ ret │ │ ret │ │ ret │ │ ret │ │ ret │ │ ret │
│cache│ │cache│ │cache│ │cache│ │cache│ │cache│
└──┬──┘ └──┬──┘ └──┬──┘ └──┬──┘ └──┬──┘ └──┬──┘
│ │ │ │ │ │
└────┬────┘ └────┬────┘ └────┬────┘
│ │ │
└────────────────────┼────────────────────┘
┌─────────────────┐
│ FAN-IN: Merge │
│ all metrics + │
│ serialize to │
│ Prometheus fmt │
└────────┬────────┘
┌─────────────────┐
│ HTTP Response │
│ text/plain │
└─────────────────┘
┌──────────────────────────────────────────────────────────┐
│ NOTE: Request path is FAST - just reads cached metrics │
│ No network calls to Cloudflare API during scrape │
│ (unless account list cache is stale) │
└──────────────────────────────────────────────────────────┘
```
### Background Refresh Path: Alarm-Driven Metric Fetching
```
┌──────────────────────────────────────────────┐
│ ALARM TRIGGERS │
│ AccountMetricCoordinator: every 60s │
│ MetricExporter: every 60s + 1-5s fixed jitter│
└──────────────────────────────────────────────┘
```
**AccountMetricCoordinator.alarm()**
```
┌────────────────────────────────────────────────────────────────────────┐
│ AccountMetricCoordinator.refresh() │
│ │
│ 1. Check zone cache (TTL: 1800s / 30 min) │
│ │
│ 2. If stale: │
│ ┌────────────────────────────────────────────────────────────────┐ │
│ │ REST: getZones(accountId) │ │
│ │ └─► DataLoader batches if multiple calls same tick │ │
│ └────────────────────────────────────────────────────────────────┘ │
│ ┌────────────────────────────────────────────────────────────────┐ │
│ │ REST: getFirewallRules(zoneId) × N zones (parallel) │ │
│ │ └─► DataLoader batches parallel calls │ │
│ └────────────────────────────────────────────────────────────────┘ │
│ │
│ 3. Push context to MetricExporter DOs: │
│ ┌────────────────────────────────────────────────────────────────┐ │
│ │ Account-scoped (13 exporters): │ │
│ │ exporter.updateZoneContext(accountId, accountName, zones) │ │
│ │ │ │
│ │ Zone-scoped (N exporters, 1 per zone): │ │
│ │ exporter.initializeZone(zone, accountId, accountName) │ │
│ └────────────────────────────────────────────────────────────────┘ │
│ │
│ 4. Schedule next alarm (60s) │
└────────────────────────────────────────────────────────────────────────┘
```
**MetricExporter.alarm()**
```
┌────────────────────────────────────────────────────────────────────────┐
│ MetricExporter.refresh() for account-scoped queries │
│ │
│ Query Types (13 total): │
│ ├── ACCOUNT-LEVEL (single account per query, 3): │
│ │ ├── worker-totals │
│ │ ├── logpush-account │
│ │ └── magic-transit │
│ │ │
│ └── ZONE-LEVEL (all zones batched in one query, 10): │
│ ├── http-metrics │
│ ├── adaptive-metrics │
│ ├── edge-country-metrics │
│ ├── colo-metrics │
│ ├── colo-error-metrics │
│ ├── request-method-metrics │
│ ├── health-check-metrics │
│ ├── load-balancer-metrics │
│ ├── logpush-zone │
│ └── origin-status-metrics │
│ │
│ After fetch: Process counters → Cache metrics → Schedule next alarm │
│ Jitter: 1-5s fixed (tighter clustering for time range alignment) │
└────────────────────────────────────────────────────────────────────────┘
```
## Development
```bash
bun install # Install dependencies
bun run dev # Run locally (port 8787)
bun run check # Lint + format check
bun run deploy # Deploy to Cloudflare
```
## Tech Stack
- **[Hono](https://hono.dev/)** - Web framework
- **[urql](https://formidable.com/open-source/urql/)** - GraphQL client
- **[gql.tada](https://gql-tada.0no.co/)** - Type-safe GraphQL
- **[Zod](https://zod.dev/)** - Schema validation
- **[DataLoader](https://github.com/graphql/dataloader)** - Request batching
- **[Cloudflare SDK](https://developers.cloudflare.com/api/)** - REST API client
- **[Cloudflare KV](https://developers.cloudflare.com/kv/)** - Runtime config persistence
## License
MIT

40
biome.json Normal file
View File

@@ -0,0 +1,40 @@
{
"$schema": "https://biomejs.dev/schemas/2.3.8/schema.json",
"vcs": {
"enabled": true,
"clientKind": "git",
"useIgnoreFile": true
},
"files": {
"ignoreUnknown": false,
"includes": [
"**",
"!worker-configuration.d.ts",
"!src/gql/graphql-env.d.ts",
"!src/gql/schema.gql"
]
},
"formatter": {
"enabled": true,
"indentStyle": "tab"
},
"linter": {
"enabled": true,
"rules": {
"recommended": true
}
},
"javascript": {
"formatter": {
"quoteStyle": "double"
}
},
"assist": {
"enabled": true,
"actions": {
"source": {
"organizeImports": "on"
}
}
}
}

332
bun.lock Normal file
View File

@@ -0,0 +1,332 @@
{
"lockfileVersion": 1,
"workspaces": {
"": {
"name": "cloudflare-prometheus-exporter-v2",
"dependencies": {
"@urql/core": "^6.0.1",
"cloudflare": "^5.2.0",
"consola": "^3.4.2",
"dataloader": "^2.2.3",
"gql.tada": "^1.9.0",
"graphql": "^16.12.0",
"install": "^0.13.0",
"zod": "^4.1.13",
},
"devDependencies": {
"@biomejs/biome": "^2.3.8",
"typescript": "^5.9.3",
"wrangler": "^4.51.0",
},
},
},
"packages": {
"@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=="],
"@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=="],
"@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=="],
"@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.3.8", "", { "os": "darwin", "cpu": "arm64" }, "sha512-HM4Zg9CGQ3txTPflxD19n8MFPrmUAjaC7PQdLkugeeC0cQ+PiVrd7i09gaBS/11QKsTDBJhVg85CEIK9f50Qww=="],
"@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.3.8", "", { "os": "darwin", "cpu": "x64" }, "sha512-lUDQ03D7y/qEao7RgdjWVGCu+BLYadhKTm40HkpJIi6kn8LSv5PAwRlew/DmwP4YZ9ke9XXoTIQDO1vAnbRZlA=="],
"@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.3.8", "", { "os": "linux", "cpu": "arm64" }, "sha512-Uo1OJnIkJgSgF+USx970fsM/drtPcQ39I+JO+Fjsaa9ZdCN1oysQmy6oAGbyESlouz+rzEckLTF6DS7cWse95g=="],
"@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.3.8", "", { "os": "linux", "cpu": "arm64" }, "sha512-PShR4mM0sjksUMyxbyPNMxoKFPVF48fU8Qe8Sfx6w6F42verbwRLbz+QiKNiDPRJwUoMG1nPM50OBL3aOnTevA=="],
"@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.3.8", "", { "os": "linux", "cpu": "x64" }, "sha512-QDPMD5bQz6qOVb3kiBui0zKZXASLo0NIQ9JVJio5RveBEFgDgsvJFUvZIbMbUZT3T00M/1wdzwWXk4GIh0KaAw=="],
"@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.3.8", "", { "os": "linux", "cpu": "x64" }, "sha512-YGLkqU91r1276uwSjiUD/xaVikdxgV1QpsicT0bIA1TaieM6E5ibMZeSyjQ/izBn4tKQthUSsVZacmoJfa3pDA=="],
"@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.3.8", "", { "os": "win32", "cpu": "arm64" }, "sha512-H4IoCHvL1fXKDrTALeTKMiE7GGWFAraDwBYFquE/L/5r1927Te0mYIGseXi4F+lrrwhSWbSGt5qPFswNoBaCxg=="],
"@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.3.8", "", { "os": "win32", "cpu": "x64" }, "sha512-RguzimPoZWtBapfKhKjcWXBVI91tiSprqdBYu7tWhgN8pKRZhw24rFeNZTNf6UiBfjCYCi9eFQs/JzJZIhuK4w=="],
"@cloudflare/kv-asset-handler": ["@cloudflare/kv-asset-handler@0.4.1", "", { "dependencies": { "mime": "^3.0.0" } }, "sha512-Nu8ahitGFFJztxUml9oD/DLb7Z28C8cd8F46IVQ7y5Btz575pvMY8AqZsXkX7Gds29eCKdMgIHjIvzskHgPSFg=="],
"@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=="],
"@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20251125.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-xDIVJi8fPxBseRoEIzLiUJb0N+DXnah/ynS+Unzn58HEoKLetUWiV/T1Fhned//lo5krnToG9KRgVRs0SOOTpw=="],
"@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20251125.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-k5FQET5PXnWjeDqZUpl4Ah/Rn0bH6mjfUtTyeAy6ky7QB3AZpwIhgWQD0vOFB3OvJaK4J/K4cUtNChYXB9mY/A=="],
"@cloudflare/workerd-linux-64": ["@cloudflare/workerd-linux-64@1.20251125.0", "", { "os": "linux", "cpu": "x64" }, "sha512-at6n/FomkftykWx0EqVLUZ0juUFz3ORtEPeBbW9ZZ3BQEyfVUtYfdcz/f1cN8Yyb7TE9ovF071P0mBRkx83ODw=="],
"@cloudflare/workerd-linux-arm64": ["@cloudflare/workerd-linux-arm64@1.20251125.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-EiRn+jrNaIs1QveabXGHFoyn3s/l02ui6Yp3nssyNhtmtgviddtt8KObBfM1jQKjXTpZlunhwdN4Bxf4jhlOMw=="],
"@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20251125.0", "", { "os": "win32", "cpu": "x64" }, "sha512-6fdIsSeu65g++k8Y2DKzNKs0BkoU+KKI6GAAVBOLh2vvVWWnCP1OgMdVb5JAdjDrjDT5i0GSQu0bgQ8fPsW6zw=="],
"@cspotcode/source-map-support": ["@cspotcode/source-map-support@0.8.1", "", { "dependencies": { "@jridgewell/trace-mapping": "0.3.9" } }, "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw=="],
"@emnapi/runtime": ["@emnapi/runtime@1.7.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA=="],
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.4", "", { "os": "aix", "cpu": "ppc64" }, "sha512-1VCICWypeQKhVbE9oW/sJaAmjLxhVqacdkvPLEjwlttjfwENRSClS8EjBz0KzRyFSCPDIkuXW34Je/vk7zdB7Q=="],
"@esbuild/android-arm": ["@esbuild/android-arm@0.25.4", "", { "os": "android", "cpu": "arm" }, "sha512-QNdQEps7DfFwE3hXiU4BZeOV68HHzYwGd0Nthhd3uCkkEKK7/R6MTgM0P7H7FAs5pU/DIWsviMmEGxEoxIZ+ZQ=="],
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.4", "", { "os": "android", "cpu": "arm64" }, "sha512-bBy69pgfhMGtCnwpC/x5QhfxAz/cBgQ9enbtwjf6V9lnPI/hMyT9iWpR1arm0l3kttTr4L0KSLpKmLp/ilKS9A=="],
"@esbuild/android-x64": ["@esbuild/android-x64@0.25.4", "", { "os": "android", "cpu": "x64" }, "sha512-TVhdVtQIFuVpIIR282btcGC2oGQoSfZfmBdTip2anCaVYcqWlZXGcdcKIUklfX2wj0JklNYgz39OBqh2cqXvcQ=="],
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Y1giCfM4nlHDWEfSckMzeWNdQS31BQGs9/rouw6Ub91tkK79aIMTH3q9xHvzH8d0wDru5Ci0kWB8b3up/nl16g=="],
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-CJsry8ZGM5VFVeyUYB3cdKpd/H69PYez4eJh1W/t38vzutdjEjtP7hB6eLKBoOdxcAlCtEYHzQ/PJ/oU9I4u0A=="],
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.4", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-yYq+39NlTRzU2XmoPW4l5Ifpl9fqSk0nAJYM/V/WUGPEFfek1epLHJIkTQM6bBs1swApjO5nWgvr843g6TjxuQ=="],
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.4", "", { "os": "freebsd", "cpu": "x64" }, "sha512-0FgvOJ6UUMflsHSPLzdfDnnBBVoCDtBTVyn/MrWloUNvq/5SFmh13l3dvgRPkDihRxb77Y17MbqbCAa2strMQQ=="],
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.4", "", { "os": "linux", "cpu": "arm" }, "sha512-kro4c0P85GMfFYqW4TWOpvmF8rFShbWGnrLqlzp4X1TNWjRY3JMYUfDCtOxPKOIY8B0WC8HN51hGP4I4hz4AaQ=="],
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-+89UsQTfXdmjIvZS6nUnOOLoXnkUTB9hR5QAeLrQdzOSWZvNSAXAtcRDHWtqAUtAmv7ZM1WPOOeSxDzzzMogiQ=="],
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.4", "", { "os": "linux", "cpu": "ia32" }, "sha512-yTEjoapy8UP3rv8dB0ip3AfMpRbyhSN3+hY8mo/i4QXFeDxmiYbEKp3ZRjBKcOP862Ua4b1PDfwlvbuwY7hIGQ=="],
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.4", "", { "os": "linux", "cpu": "none" }, "sha512-NeqqYkrcGzFwi6CGRGNMOjWGGSYOpqwCjS9fvaUlX5s3zwOtn1qwg1s2iE2svBe4Q/YOG1q6875lcAoQK/F4VA=="],
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.4", "", { "os": "linux", "cpu": "none" }, "sha512-IcvTlF9dtLrfL/M8WgNI/qJYBENP3ekgsHbYUIzEzq5XJzzVEV/fXY9WFPfEEXmu3ck2qJP8LG/p3Q8f7Zc2Xg=="],
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-HOy0aLTJTVtoTeGZh4HSXaO6M95qu4k5lJcH4gxv56iaycfz1S8GO/5Jh6X4Y1YiI0h7cRyLi+HixMR+88swag=="],
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.4", "", { "os": "linux", "cpu": "none" }, "sha512-i8JUDAufpz9jOzo4yIShCTcXzS07vEgWzyX3NH2G7LEFVgrLEhjwL3ajFE4fZI3I4ZgiM7JH3GQ7ReObROvSUA=="],
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-jFnu+6UbLlzIjPQpWCNh5QtrcNfMLjgIavnwPQAfoGx4q17ocOU9MsQ2QVvFxwQoWpZT8DvTLooTvmOQXkO51g=="],
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.4", "", { "os": "linux", "cpu": "x64" }, "sha512-6e0cvXwzOnVWJHq+mskP8DNSrKBr1bULBvnFLpc1KY+d+irZSgZ02TGse5FsafKS5jg2e4pbvK6TPXaF/A6+CA=="],
"@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.4", "", { "os": "none", "cpu": "arm64" }, "sha512-vUnkBYxZW4hL/ie91hSqaSNjulOnYXE1VSLusnvHg2u3jewJBz3YzB9+oCw8DABeVqZGg94t9tyZFoHma8gWZQ=="],
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.4", "", { "os": "none", "cpu": "x64" }, "sha512-XAg8pIQn5CzhOB8odIcAm42QsOfa98SBeKUdo4xa8OvX8LbMZqEtgeWE9P/Wxt7MlG2QqvjGths+nq48TrUiKw=="],
"@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.4", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-Ct2WcFEANlFDtp1nVAXSNBPDxyU+j7+tId//iHXU2f/lN5AmO4zLyhDcpR5Cz1r08mVxzt3Jpyt4PmXQ1O6+7A=="],
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.4", "", { "os": "openbsd", "cpu": "x64" }, "sha512-xAGGhyOQ9Otm1Xu8NT1ifGLnA6M3sJxZ6ixylb+vIUVzvvd6GOALpwQrYrtlPouMqd/vSbgehz6HaVk4+7Afhw=="],
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.4", "", { "os": "sunos", "cpu": "x64" }, "sha512-Mw+tzy4pp6wZEK0+Lwr76pWLjrtjmJyUB23tHKqEDP74R3q95luY/bXqXZeYl4NYlvwOqoRKlInQialgCKy67Q=="],
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-AVUP428VQTSddguz9dO9ngb+E5aScyg7nOeJDrF1HPYu555gmza3bDGMPhmVXL8svDSoqPCsCPjb265yG/kLKQ=="],
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.4", "", { "os": "win32", "cpu": "ia32" }, "sha512-i1sW+1i+oWvQzSgfRcxxG2k4I9n3O9NRqy8U+uugaT2Dy7kLO9Y7wI72haOahxceMX8hZAzgGou1FhndRldxRg=="],
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.4", "", { "os": "win32", "cpu": "x64" }, "sha512-nOT2vZNw6hJ+z43oP1SPea/G/6AbN6X+bGNhNuq8NtRHy4wsMhw765IKLNmnjek7GvjWBYQ8Q5VBoYTFg9y1UQ=="],
"@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=="],
"@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=="],
"@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=="],
"@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=="],
"@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.0.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg=="],
"@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.0.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ=="],
"@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.0.5", "", { "os": "linux", "cpu": "arm" }, "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g=="],
"@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.0.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA=="],
"@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.0.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA=="],
"@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.0.4", "", { "os": "linux", "cpu": "x64" }, "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw=="],
"@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.0.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA=="],
"@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.0.4", "", { "os": "linux", "cpu": "x64" }, "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw=="],
"@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=="],
"@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=="],
"@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=="],
"@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=="],
"@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=="],
"@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=="],
"@img/sharp-wasm32": ["@img/sharp-wasm32@0.33.5", "", { "dependencies": { "@emnapi/runtime": "^1.2.0" }, "cpu": "none" }, "sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg=="],
"@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.33.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ=="],
"@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.33.5", "", { "os": "win32", "cpu": "x64" }, "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg=="],
"@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.9", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.0.3", "@jridgewell/sourcemap-codec": "^1.4.10" } }, "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ=="],
"@poppinss/colors": ["@poppinss/colors@4.1.5", "", { "dependencies": { "kleur": "^4.1.5" } }, "sha512-FvdDqtcRCtz6hThExcFOgW0cWX+xwSMWcRuQe5ZEb2m7cVQOAVZOIMt+/v9RxGiD9/OY16qJBXK4CVKWAPalBw=="],
"@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=="],
"@poppinss/exception": ["@poppinss/exception@1.2.2", "", {}, "sha512-m7bpKCD4QMlFCjA/nKTs23fuvoVFoA83brRKmObCUNmi/9tVu8Ve3w4YQAnJu4q3Tjf5fr685HYIC/IA2zHRSg=="],
"@sindresorhus/is": ["@sindresorhus/is@7.1.1", "", {}, "sha512-rO92VvpgMc3kfiTjGT52LEtJ8Yc5kCWhZjLQ3LwlA4pSgPpQO7bVpYXParOD8Jwf+cVQECJo3yP/4I8aZtUQTQ=="],
"@speed-highlight/core": ["@speed-highlight/core@1.2.12", "", {}, "sha512-uilwrK0Ygyri5dToHYdZSjcvpS2ZwX0w5aSt3GCEN9hrjxWCoeV4Z2DTXuxjwbntaLQIEEAlCeNQss5SoHvAEA=="],
"@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="],
"@types/node-fetch": ["@types/node-fetch@2.6.13", "", { "dependencies": { "@types/node": "*", "form-data": "^4.0.4" } }, "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw=="],
"@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=="],
"abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="],
"acorn": ["acorn@8.14.0", "", { "bin": "bin/acorn" }, "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA=="],
"acorn-walk": ["acorn-walk@8.3.2", "", {}, "sha512-cjkyv4OtNCIeqhHrfS81QWXoCBPExR/J62oyEqepVw8WaQeSqpW2uhuLPh1m9eWhDuOo/jUXVTlifvesOWp/4A=="],
"agentkeepalive": ["agentkeepalive@4.6.0", "", { "dependencies": { "humanize-ms": "^1.2.1" } }, "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ=="],
"asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="],
"blake3-wasm": ["blake3-wasm@2.1.5", "", {}, "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g=="],
"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=="],
"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=="],
"color": ["color@4.2.3", "", { "dependencies": { "color-convert": "^2.0.1", "color-string": "^1.9.0" } }, "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A=="],
"color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
"color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
"color-string": ["color-string@1.9.1", "", { "dependencies": { "color-name": "^1.0.0", "simple-swizzle": "^0.2.2" } }, "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg=="],
"combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="],
"consola": ["consola@3.4.2", "", {}, "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA=="],
"cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="],
"dataloader": ["dataloader@2.2.3", "", {}, "sha512-y2krtASINtPFS1rSDjacrFgn1dcUuoREVabwlOGOe4SdxenREqwjwjElAdwvbGM7kgZz9a3KVicWR7vcz8rnzA=="],
"delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="],
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
"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=="],
"error-stack-parser-es": ["error-stack-parser-es@1.0.5", "", {}, "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA=="],
"es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="],
"es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
"es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="],
"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=="],
"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=="],
"event-target-shim": ["event-target-shim@5.0.1", "", {}, "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ=="],
"exit-hook": ["exit-hook@2.2.1", "", {}, "sha512-eNTPlAD67BmP31LDINZ3U7HSF8l57TxOY2PmBJ1shpCvpnxBF93mWCE8YHBnXs8qiUZJc9WDcWIeC3a2HIAMfw=="],
"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=="],
"form-data-encoder": ["form-data-encoder@1.7.2", "", {}, "sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A=="],
"formdata-node": ["formdata-node@4.4.1", "", { "dependencies": { "node-domexception": "1.0.0", "web-streams-polyfill": "4.0.0-beta.3" } }, "sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ=="],
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
"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=="],
"get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="],
"glob-to-regexp": ["glob-to-regexp@0.4.1", "", {}, "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw=="],
"gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="],
"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=="],
"graphql": ["graphql@16.12.0", "", {}, "sha512-DKKrynuQRne0PNpEbzuEdHlYOMksHSUI8Zc9Unei5gTsMNA2/vMpoMz/yKba50pejK56qj98qM0SjYxAKi13gQ=="],
"has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="],
"has-tostringtag": ["has-tostringtag@1.0.2", "", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="],
"hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
"humanize-ms": ["humanize-ms@1.2.1", "", { "dependencies": { "ms": "^2.0.0" } }, "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ=="],
"install": ["install@0.13.0", "", {}, "sha512-zDml/jzr2PKU9I8J/xyZBQn8rPCAY//UOYNmR01XwNwyfhEWObo2SWfSl1+0tm1u6PhxLwDnfsT/6jB7OUxqFA=="],
"is-arrayish": ["is-arrayish@0.3.4", "", {}, "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA=="],
"kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="],
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
"mime": ["mime@3.0.0", "", { "bin": "cli.js" }, "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A=="],
"mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
"mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
"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=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"node-domexception": ["node-domexception@1.0.0", "", {}, "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ=="],
"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=="],
"path-to-regexp": ["path-to-regexp@6.3.0", "", {}, "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ=="],
"pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
"semver": ["semver@7.7.3", "", { "bin": "bin/semver.js" }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="],
"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=="],
"simple-swizzle": ["simple-swizzle@0.2.4", "", { "dependencies": { "is-arrayish": "^0.3.1" } }, "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw=="],
"stoppable": ["stoppable@1.1.0", "", {}, "sha512-KXDYZ9dszj6bzvnEMRYvxgeTHU74QBFL54XKtP3nyMuJ81CFYtABZ3bAzL2EdFUaEwJOBOgENyFj3R7oTzDyyw=="],
"supports-color": ["supports-color@10.2.2", "", {}, "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g=="],
"tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="],
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"undici": ["undici@7.14.0", "", {}, "sha512-Vqs8HTzjpQXZeXdpsfChQTlafcMQaaIwnGwLam1wudSSjlJeQ3bw1j+TLPePgrCnCpUXx7Ba5Pdpf5OBih62NQ=="],
"undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="],
"unenv": ["unenv@2.0.0-rc.24", "", { "dependencies": { "pathe": "^2.0.3" } }, "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw=="],
"web-streams-polyfill": ["web-streams-polyfill@4.0.0-beta.3", "", {}, "sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug=="],
"webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="],
"whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="],
"wonka": ["wonka@6.3.5", "", {}, "sha512-SSil+ecw6B4/Dm7Pf2sAshKQ5hWFvfyGlfPbEd6A14dOH6VDjrmbY86u6nZvy9omGwwIPFR8V41+of1EezgoUw=="],
"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=="],
"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=="],
"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=="],
"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=="],
"youch-core": ["youch-core@0.3.3", "", { "dependencies": { "@poppinss/exception": "^1.2.2", "error-stack-parser-es": "^1.0.5" } }, "sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA=="],
"zod": ["zod@4.1.13", "", {}, "sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig=="],
"miniflare/zod": ["zod@3.22.3", "", {}, "sha512-EjIevzuJRiRPbVH4mGc8nApb/lVLKVpmUhAaR5R5doKGfAnGJ6Gr3CViAVjP+4FWSxCsybeWQdcgCtbX+7oZug=="],
}
}

9
docker-compose.yml Normal file
View File

@@ -0,0 +1,9 @@
services:
prometheus:
image: prom/prometheus:latest
ports:
- "9090:9090"
volumes:
- ./prometheus.yml:/etc/prometheus/prometheus.yml
extra_hosts:
- "host.docker.internal:host-gateway"

1534
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,15 +1,31 @@
{
"name": "cloudflare-prometheus-exporter-v2",
"version": "0.0.0",
"name": "cloudflare-prometheus-exporter",
"version": "0.0.1",
"private": true,
"scripts": {
"deploy": "wrangler deploy",
"dev": "wrangler dev",
"start": "wrangler dev",
"cf-typegen": "wrangler types"
"cf-typegen": "wrangler types",
"gql:generate": "gql.tada generate output",
"check": "biome check",
"format": "biome format --write",
"lint": "biome lint"
},
"devDependencies": {
"typescript": "^5.5.2",
"@biomejs/biome": "^2.3.8",
"typescript": "^5.9.3",
"wrangler": "^4.51.0"
},
"dependencies": {
"@urql/core": "^6.0.1",
"cloudflare": "^5.2.0",
"consola": "^3.4.2",
"dataloader": "^2.2.3",
"gql.tada": "^1.9.0",
"graphql": "^16.12.0",
"hono": "^4.10.7",
"install": "^0.13.0",
"zod": "^4.1.13"
}
}
}

10
prometheus.yml Normal file
View File

@@ -0,0 +1,10 @@
global:
scrape_interval: 60s
evaluation_interval: 60s
scrape_configs:
- job_name: "cloudflare-exporter"
static_configs:
- targets: ["host.docker.internal:8787"]
metrics_path: /metrics
scrape_timeout: 30s

2460
src/cloudflare/client.ts Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,21 @@
import { initGraphQLTada } from "gql.tada";
import type { introspection } from "./graphql-env";
export const graphql = initGraphQLTada<{
introspection: introspection;
scalars: {
Date: string;
DateTime: string;
Time: string;
bytes: string;
float32: number;
float64: number;
string: string;
uint8: number;
uint16: number;
uint32: number;
uint64: number;
};
}>();
export type { FragmentOf, ResultOf, VariablesOf } from "gql.tada";

1460
src/cloudflare/gql/graphql-env.d.ts vendored Normal file

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,639 @@
import { graphql } from "./client";
export const HTTPMetricsQuery = graphql(`
query HTTPMetrics(
$zoneIDs: [string!]
$mintime: Time!
$maxtime: Time!
$limit: uint64!
) {
viewer {
zones(filter: { zoneTag_in: $zoneIDs }) {
zoneTag
httpRequests1mGroups(
limit: $limit
filter: { datetime_geq: $mintime, datetime_lt: $maxtime }
) {
uniq {
uniques
}
sum {
browserMap {
pageViews
uaBrowserFamily
}
bytes
cachedBytes
cachedRequests
contentTypeMap {
bytes
requests
edgeResponseContentTypeName
}
countryMap {
bytes
clientCountryName
requests
threats
}
encryptedBytes
encryptedRequests
pageViews
requests
responseStatusMap {
edgeResponseStatus
requests
}
threatPathingMap {
requests
threatPathingName
}
threats
clientHTTPVersionMap {
clientHTTPProtocol
requests
}
clientSSLMap {
clientSSLProtocol
requests
}
ipClassMap {
ipType
requests
}
}
dimensions {
datetime
}
}
firewallEventsAdaptiveGroups(
limit: $limit
filter: { datetime_geq: $mintime, datetime_lt: $maxtime }
) {
count
dimensions {
action
source
ruleId
clientRequestHTTPHost
clientCountryName
botScore
botScoreSrcName
}
}
}
}
}
`);
export const HTTPMetricsQueryNoBots = graphql(`
query HTTPMetricsNoBots(
$zoneIDs: [string!]
$mintime: Time!
$maxtime: Time!
$limit: uint64!
) {
viewer {
zones(filter: { zoneTag_in: $zoneIDs }) {
zoneTag
httpRequests1mGroups(
limit: $limit
filter: { datetime_geq: $mintime, datetime_lt: $maxtime }
) {
uniq {
uniques
}
sum {
browserMap {
pageViews
uaBrowserFamily
}
bytes
cachedBytes
cachedRequests
contentTypeMap {
bytes
requests
edgeResponseContentTypeName
}
countryMap {
bytes
clientCountryName
requests
threats
}
encryptedBytes
encryptedRequests
pageViews
requests
responseStatusMap {
edgeResponseStatus
requests
}
threatPathingMap {
requests
threatPathingName
}
threats
clientHTTPVersionMap {
clientHTTPProtocol
requests
}
clientSSLMap {
clientSSLProtocol
requests
}
ipClassMap {
ipType
requests
}
}
dimensions {
datetime
}
}
firewallEventsAdaptiveGroups(
limit: $limit
filter: { datetime_geq: $mintime, datetime_lt: $maxtime }
) {
count
dimensions {
action
source
ruleId
clientRequestHTTPHost
clientCountryName
}
}
}
}
}
`);
export const FirewallMetricsQuery = graphql(`
query FirewallMetrics(
$zoneIDs: [string!]
$mintime: Time!
$maxtime: Time!
$limit: uint64!
) {
viewer {
zones(filter: { zoneTag_in: $zoneIDs }) {
zoneTag
firewallEventsAdaptiveGroups(
limit: $limit
filter: { datetime_geq: $mintime, datetime_lt: $maxtime }
) {
count
dimensions {
action
source
ruleId
clientRequestHTTPHost
clientCountryName
}
}
}
}
}
`);
export const HealthCheckMetricsQuery = graphql(`
query HealthCheckMetrics(
$zoneIDs: [string!]
$mintime: Time!
$maxtime: Time!
$limit: uint64!
) {
viewer {
zones(filter: { zoneTag_in: $zoneIDs }) {
zoneTag
healthCheckEventsAdaptiveGroups(
limit: $limit
filter: { datetime_geq: $mintime, datetime_lt: $maxtime }
) {
count
avg {
rttMs
timeToFirstByteMs
tcpConnMs
tlsHandshakeMs
}
dimensions {
healthStatus
originIP
region
fqdn
failureReason
}
}
}
}
}
`);
export const AdaptiveMetricsQuery = graphql(`
query AdaptiveMetrics(
$zoneIDs: [string!]
$mintime: Time!
$maxtime: Time!
$limit: uint64!
) {
viewer {
zones(filter: { zoneTag_in: $zoneIDs }) {
zoneTag
httpRequestsAdaptiveGroups(
limit: $limit
filter: {
datetime_geq: $mintime
datetime_lt: $maxtime
cacheStatus_notin: ["hit"]
originResponseStatus_in: [
400
404
500
502
503
504
522
523
524
]
}
) {
count
dimensions {
originResponseStatus
clientCountryName
clientRequestHTTPHost
}
avg {
originResponseDurationMs
}
}
}
}
}
`);
export const EdgeCountryMetricsQuery = graphql(`
query EdgeCountryMetrics(
$zoneIDs: [string!]
$mintime: Time!
$maxtime: Time!
$limit: uint64!
) {
viewer {
zones(filter: { zoneTag_in: $zoneIDs }) {
zoneTag
httpRequestsEdgeCountryHost: httpRequestsAdaptiveGroups(
limit: $limit
filter: { datetime_geq: $mintime, datetime_lt: $maxtime }
) {
count
dimensions {
edgeResponseStatus
clientCountryName
clientRequestHTTPHost
}
}
}
}
}
`);
export const ColoMetricsQuery = graphql(`
query ColoMetrics(
$zoneIDs: [string!]
$mintime: Time!
$maxtime: Time!
$limit: uint64!
) {
viewer {
zones(filter: { zoneTag_in: $zoneIDs }) {
zoneTag
httpRequestsAdaptiveGroups(
limit: $limit
filter: { datetime_geq: $mintime, datetime_lt: $maxtime }
) {
count
avg {
sampleInterval
}
dimensions {
clientRequestHTTPHost
coloCode
datetime
originResponseStatus
}
sum {
edgeResponseBytes
visits
}
}
}
}
}
`);
export const ColoErrorMetricsQuery = graphql(`
query ColoErrorMetrics(
$zoneIDs: [string!]
$mintime: Time!
$maxtime: Time!
$limit: uint64!
) {
viewer {
zones(filter: { zoneTag_in: $zoneIDs }) {
zoneTag
httpRequestsAdaptiveGroups(
limit: $limit
filter: {
datetime_geq: $mintime
datetime_lt: $maxtime
edgeResponseStatus_geq: 400
}
) {
count
dimensions {
clientRequestHTTPHost
coloCode
edgeResponseStatus
}
sum {
edgeResponseBytes
visits
}
}
}
}
}
`);
export const WorkerTotalsQuery = graphql(`
query WorkerTotals(
$accountID: string!
$mintime: Time!
$maxtime: Time!
$limit: uint64!
) {
viewer {
accounts(filter: { accountTag: $accountID }) {
workersInvocationsAdaptive(
limit: $limit
filter: { datetime_geq: $mintime, datetime_lt: $maxtime }
) {
dimensions {
scriptName
status
}
sum {
requests
errors
duration
}
quantiles {
cpuTimeP50
cpuTimeP75
cpuTimeP99
cpuTimeP999
durationP50
durationP75
durationP99
durationP999
}
}
}
}
}
`);
// Note: Cloudflare's accounts filter only supports single accountTag, not accountTag_in
// Use WorkerTotalsQuery for individual account queries
export const LoadBalancerMetricsQuery = graphql(`
query LoadBalancerMetrics(
$zoneIDs: [string!]
$mintime: Time!
$maxtime: Time!
$limit: uint64!
) {
viewer {
zones(filter: { zoneTag_in: $zoneIDs }) {
zoneTag
loadBalancingRequestsAdaptiveGroups(
filter: { datetime_geq: $mintime, datetime_lt: $maxtime }
limit: $limit
) {
count
dimensions {
lbName
selectedPoolName
selectedOriginName
region
proxied
selectedPoolAvgRttMs
selectedPoolHealthy
steeringPolicy
numberOriginsSelected
}
}
loadBalancingRequestsAdaptive(
filter: { datetime_geq: $mintime, datetime_lt: $maxtime }
limit: $limit
) {
lbName
pools {
id
poolName
healthy
healthCheckEnabled
avgRttMs
}
}
}
}
}
`);
export const LogpushAccountMetricsQuery = graphql(`
query LogpushAccountMetrics(
$accountID: string!
$limit: uint64!
$mintime: Time!
$maxtime: Time!
) {
viewer {
accounts(filter: { accountTag: $accountID }) {
logpushHealthAdaptiveGroups(
filter: {
datetime_geq: $mintime
datetime_lt: $maxtime
status_neq: 200
}
limit: $limit
) {
count
dimensions {
jobId
status
destinationType
datetime
final
}
}
}
}
}
`);
// Note: Cloudflare's accounts filter only supports single accountTag, not accountTag_in
// Use LogpushAccountMetricsQuery for individual account queries
export const LogpushZoneMetricsQuery = graphql(`
query LogpushZoneMetrics(
$zoneIDs: [string!]
$limit: uint64!
$mintime: Time!
$maxtime: Time!
) {
viewer {
zones(filter: { zoneTag_in: $zoneIDs }) {
zoneTag
logpushHealthAdaptiveGroups(
filter: {
datetime_geq: $mintime
datetime_lt: $maxtime
status_neq: 200
}
limit: $limit
) {
count
dimensions {
jobId
status
destinationType
datetime
final
}
}
}
}
}
`);
export const MagicTransitMetricsQuery = graphql(`
query MagicTransitMetrics(
$accountID: string!
$limit: uint64!
$mintime: Time!
$maxtime: Time!
) {
viewer {
accounts(filter: { accountTag: $accountID }) {
magicTransitTunnelHealthChecksAdaptiveGroups(
limit: $limit
filter: { datetime_geq: $mintime, datetime_lt: $maxtime }
) {
count
dimensions {
active
datetime
edgeColoCity
edgeColoCountry
edgePopName
remoteTunnelIPv4
resultStatus
siteName
tunnelName
}
}
}
}
}
`);
// Note: Cloudflare's accounts filter only supports single accountTag, not accountTag_in
// Use MagicTransitMetricsQuery for individual account queries
export const RequestMethodMetricsQuery = graphql(`
query RequestMethodMetrics(
$zoneIDs: [string!]
$mintime: Time!
$maxtime: Time!
$limit: uint64!
) {
viewer {
zones(filter: { zoneTag_in: $zoneIDs }) {
zoneTag
httpRequestsAdaptiveGroups(
limit: $limit
filter: { datetime_geq: $mintime, datetime_lt: $maxtime }
) {
count
dimensions {
clientRequestHTTPMethodName
}
}
}
}
}
`);
export const OriginStatusMetricsQuery = graphql(`
query OriginStatusMetrics(
$zoneIDs: [string!]
$mintime: Time!
$maxtime: Time!
$limit: uint64!
) {
viewer {
zones(filter: { zoneTag_in: $zoneIDs }) {
zoneTag
httpRequestsAdaptiveGroups(
limit: $limit
filter: { datetime_geq: $mintime, datetime_lt: $maxtime }
) {
count
dimensions {
originResponseStatus
clientCountryName
clientRequestHTTPHost
}
}
}
}
}
`);
export const CacheMissMetricsQuery = graphql(`
query CacheMissMetrics(
$zoneIDs: [string!]
$mintime: Time!
$maxtime: Time!
$limit: uint64!
) {
viewer {
zones(filter: { zoneTag_in: $zoneIDs }) {
zoneTag
httpRequestsAdaptiveGroups(
filter: {
datetime_geq: $mintime
datetime_lt: $maxtime
cacheStatus: "miss"
}
limit: $limit
) {
count
avg {
originResponseDurationMs
}
dimensions {
clientCountryName
clientRequestHTTPHost
}
}
}
}
}
`);

169198
src/cloudflare/gql/schema.gql Normal file

File diff suppressed because it is too large Load Diff

104
src/cloudflare/queries.ts Normal file
View File

@@ -0,0 +1,104 @@
import z from "zod";
/**
* Zod schema for all supported metric query names.
* Includes both account-level and zone-level queries.
*/
export const MetricQueryNameSchema = z.enum([
// Account-level
"worker-totals",
"logpush-account",
"magic-transit",
// Zone-level
"http-metrics",
"adaptive-metrics",
"edge-country-metrics",
"colo-metrics",
"colo-error-metrics",
"request-method-metrics",
"health-check-metrics",
"load-balancer-metrics",
"logpush-zone",
"origin-status-metrics",
"cache-miss-metrics",
// REST API
"ssl-certificates",
"lb-weight-metrics",
]);
/**
* Union of all metric query names (account and zone level).
*/
export type MetricQueryName = z.infer<typeof MetricQueryNameSchema>;
/**
* Account-scoped metric queries (require single accountTag).
*/
export const ACCOUNT_LEVEL_QUERIES = [
"worker-totals",
"logpush-account",
"magic-transit",
] as const;
/**
* Union of account-level query names.
*/
export type AccountLevelQuery = (typeof ACCOUNT_LEVEL_QUERIES)[number];
/**
* Zone-scoped metric queries (support multiple zoneIDs).
*/
export const ZONE_LEVEL_QUERIES = [
"http-metrics",
"adaptive-metrics",
"edge-country-metrics",
"colo-metrics",
"colo-error-metrics",
"request-method-metrics",
"health-check-metrics",
"load-balancer-metrics",
"logpush-zone",
"origin-status-metrics",
"cache-miss-metrics",
"ssl-certificates",
"lb-weight-metrics",
] as const;
/**
* Union of zone-level query names.
*/
export type ZoneLevelQuery = (typeof ZONE_LEVEL_QUERIES)[number];
/**
* Type guard for account-level queries.
*
* @param query Query name to check.
* @returns True if query is account-level.
*/
export function isAccountLevelQuery(query: string): query is AccountLevelQuery {
return (ACCOUNT_LEVEL_QUERIES as readonly string[]).includes(query);
}
/**
* Type guard for zone-level queries.
*
* @param query Query name to check.
* @returns True if query is zone-level.
*/
export function isZoneLevelQuery(query: string): query is ZoneLevelQuery {
return (ZONE_LEVEL_QUERIES as readonly string[]).includes(query);
}
/**
* Query types available on free tier accounts.
*/
export const FREE_TIER_QUERIES = [
"worker-totals",
"logpush-account",
"magic-transit",
] as const;
/**
* Type for free tier query names.
*/
export type FreeTierQuery = (typeof FREE_TIER_QUERIES)[number];

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,349 @@
import { html } from "hono/html";
import type { FC } from "hono/jsx";
type Props = { metricsPath: string };
export const LandingPageScript: FC<Props> = ({ metricsPath }) => {
return html`
<script>
// Config state management
let serverConfig = {};
let localConfig = {};
let defaultConfig = {};
let dirtyFields = new Set();
// Config field definitions
const configFields = [
'queryLimit', 'scrapeDelaySeconds', 'timeWindowSeconds', 'metricRefreshIntervalSeconds',
'accountListCacheTtlSeconds', 'zoneListCacheTtlSeconds', 'sslCertsCacheTtlSeconds',
'logLevel', 'logFormat', 'cfAccounts', 'cfZones', 'cfFreeTierAccounts', 'metricsDenylist',
'excludeHost', 'httpStatusGroup'
];
// Load config on page load
async function loadConfig() {
try {
const [configRes, defaultsRes] = await Promise.all([
fetch('/config'),
fetch('/config/defaults')
]);
if (!configRes.ok) throw new Error('Failed to load config');
if (!defaultsRes.ok) throw new Error('Failed to load defaults');
serverConfig = await configRes.json();
defaultConfig = await defaultsRes.json();
localConfig = { ...serverConfig };
dirtyFields.clear();
populateForm();
updateSaveButton();
} catch (e) {
console.error('Failed to load config:', e);
document.getElementById('config-status').textContent = 'Failed to load configuration';
}
}
// Populate form fields from config
function populateForm() {
// Number fields
['queryLimit', 'scrapeDelaySeconds', 'timeWindowSeconds', 'metricRefreshIntervalSeconds',
'accountListCacheTtlSeconds', 'zoneListCacheTtlSeconds', 'sslCertsCacheTtlSeconds'].forEach(key => {
const el = document.getElementById('cfg-' + key);
if (el) el.value = localConfig[key] ?? '';
});
// Select fields
['logLevel', 'logFormat'].forEach(key => {
const el = document.getElementById('cfg-' + key);
if (el) el.value = localConfig[key] ?? '';
});
// Text fields (nullable)
['cfAccounts', 'cfZones'].forEach(key => {
const el = document.getElementById('cfg-' + key);
const allCheckbox = document.getElementById('cfg-' + key + '-all');
if (el && allCheckbox) {
const isAll = localConfig[key] === null;
allCheckbox.checked = isAll;
el.value = isAll ? '' : (localConfig[key] ?? '');
el.disabled = isAll;
}
});
// Text fields (non-nullable)
['cfFreeTierAccounts', 'metricsDenylist'].forEach(key => {
const el = document.getElementById('cfg-' + key);
if (el) el.value = localConfig[key] ?? '';
});
// Toggle switches
['excludeHost', 'httpStatusGroup'].forEach(key => {
const el = document.getElementById('cfg-' + key);
if (el) {
const isActive = localConfig[key] === true;
el.classList.toggle('active', isActive);
el.setAttribute('aria-checked', isActive.toString());
}
});
}
// Track field changes
function onFieldChange(key, value) {
localConfig[key] = value;
if (JSON.stringify(value) !== JSON.stringify(serverConfig[key])) {
dirtyFields.add(key);
} else {
dirtyFields.delete(key);
}
updateSaveButton();
}
// Toggle for "All accounts/zones" checkboxes
function toggleAllFilter(key, isAll) {
const el = document.getElementById('cfg-' + key);
if (el) {
el.disabled = isAll;
if (isAll) {
el.value = '';
onFieldChange(key, null);
} else {
onFieldChange(key, el.value || null);
}
}
}
// Toggle switch handler
function toggleSwitch(key) {
const el = document.getElementById('cfg-' + key);
if (el) {
const newValue = !el.classList.contains('active');
el.classList.toggle('active', newValue);
el.setAttribute('aria-checked', newValue.toString());
onFieldChange(key, newValue);
}
}
// Save all dirty fields
async function saveConfig() {
const btn = document.getElementById('save-btn');
btn.disabled = true;
btn.textContent = 'Saving...';
const errors = [];
for (const key of dirtyFields) {
try {
const res = await fetch('/config/' + key, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ value: localConfig[key] })
});
if (!res.ok) {
const data = await res.json();
errors.push({ key, error: data.error || 'Unknown error' });
}
} catch (e) {
errors.push({ key, error: e.message });
}
}
if (errors.length === 0) {
serverConfig = { ...localConfig };
dirtyFields.clear();
showToast('Configuration saved', 'success');
} else {
showToast('Failed to save: ' + errors.map(e => e.key).join(', '), 'error');
}
btn.textContent = 'Save Changes';
updateSaveButton();
}
// Reset single field to default (updates UI only, requires Save to persist)
function resetField(key) {
const defaultValue = defaultConfig[key];
localConfig[key] = defaultValue;
if (JSON.stringify(defaultValue) !== JSON.stringify(serverConfig[key])) {
dirtyFields.add(key);
} else {
dirtyFields.delete(key);
}
updateFieldUI(key);
updateSaveButton();
}
// Reset all config to defaults (updates UI only, requires Save to persist)
function resetAllConfig() {
if (!confirm('Reset all configuration to defaults?')) return;
localConfig = { ...defaultConfig };
dirtyFields.clear();
for (const key of configFields) {
if (JSON.stringify(defaultConfig[key]) !== JSON.stringify(serverConfig[key])) {
dirtyFields.add(key);
}
}
populateForm();
updateSaveButton();
}
// Update single field UI
function updateFieldUI(key) {
const el = document.getElementById('cfg-' + key);
if (!el) return;
if (['excludeHost', 'httpStatusGroup'].includes(key)) {
const isActive = localConfig[key] === true;
el.classList.toggle('active', isActive);
el.setAttribute('aria-checked', isActive.toString());
} else if (['cfAccounts', 'cfZones'].includes(key)) {
const allCheckbox = document.getElementById('cfg-' + key + '-all');
const isAll = localConfig[key] === null;
if (allCheckbox) allCheckbox.checked = isAll;
el.disabled = isAll;
el.value = isAll ? '' : (localConfig[key] ?? '');
} else {
el.value = localConfig[key] ?? '';
}
}
// Update save button state
function updateSaveButton() {
const btn = document.getElementById('save-btn');
const status = document.getElementById('config-status');
btn.disabled = dirtyFields.size === 0;
if (dirtyFields.size > 0) {
status.textContent = dirtyFields.size + ' unsaved change' + (dirtyFields.size > 1 ? 's' : '');
} else {
status.textContent = 'All changes saved';
}
}
// Tab switching
function switchTab(tabId) {
document.querySelectorAll('.tab-btn').forEach(btn => btn.classList.remove('active'));
document.querySelectorAll('.tab-panel').forEach(panel => panel.classList.add('hidden'));
document.querySelector('[data-tab="' + tabId + '"]').classList.add('active');
document.getElementById('tab-' + tabId).classList.remove('hidden');
}
// Toast notification
function showToast(message, type) {
const toast = document.getElementById('toast');
toast.textContent = message;
toast.className = 'toast ' + type + ' show';
setTimeout(() => {
toast.classList.remove('show');
}, 3000);
}
async function checkHealth() {
const indicator = document.getElementById('health-indicator');
const status = document.getElementById('health-status');
const badge = document.getElementById('health-badge');
const cfApiIndicator = document.getElementById('cf-api-indicator');
const cfApiLatency = document.getElementById('cf-api-latency');
const cfApiError = document.getElementById('cf-api-error');
const gqlApiIndicator = document.getElementById('gql-api-indicator');
const gqlApiLatency = document.getElementById('gql-api-latency');
const gqlApiError = document.getElementById('gql-api-error');
const healthTimestamp = document.getElementById('health-timestamp');
const setCheckStatus = (indicatorEl, latencyEl, errorEl, check) => {
if (check.status === 'healthy') {
indicatorEl.className = 'w-2 h-2 rounded-full bg-green-500';
latencyEl.className = 'text-xs font-mono text-green-600';
latencyEl.textContent = check.latency_ms + 'ms';
errorEl.classList.add('hidden');
errorEl.textContent = '';
} else {
indicatorEl.className = 'w-2 h-2 rounded-full bg-red-500';
latencyEl.className = 'text-xs font-mono text-red-600';
latencyEl.textContent = check.latency_ms + 'ms';
if (check.error) {
errorEl.textContent = check.error;
errorEl.classList.remove('hidden');
} else {
errorEl.classList.add('hidden');
}
}
};
try {
const res = await fetch('/health');
const data = await res.json();
setCheckStatus(cfApiIndicator, cfApiLatency, cfApiError, data.checks.cloudflare_api);
setCheckStatus(gqlApiIndicator, gqlApiLatency, gqlApiError, data.checks.graphql_api);
const ts = new Date(data.timestamp);
healthTimestamp.textContent = 'Last checked ' + ts.toLocaleTimeString();
if (data.status === 'healthy') {
indicator.className = 'w-3 h-3 rounded-full bg-green-500 pulse-dot';
status.textContent = 'All systems operational';
badge.className = 'px-4 py-2 rounded-full text-sm font-medium bg-green-500/10 text-green-600 border border-green-500/20';
badge.textContent = 'Healthy';
} else {
const unhealthyChecks = [];
if (data.checks.cloudflare_api.status !== 'healthy') unhealthyChecks.push('REST API');
if (data.checks.graphql_api.status !== 'healthy') unhealthyChecks.push('GraphQL');
indicator.className = 'w-3 h-3 rounded-full bg-red-500';
status.textContent = 'Degraded: ' + unhealthyChecks.join(', ');
badge.className = 'px-4 py-2 rounded-full text-sm font-medium bg-red-500/10 text-red-600 border border-red-500/20';
badge.textContent = 'Unhealthy';
}
} catch {
indicator.className = 'w-3 h-3 rounded-full bg-red-500';
status.textContent = 'Unable to reach health endpoint';
badge.className = 'px-4 py-2 rounded-full text-sm font-medium bg-red-500/10 text-red-600 border border-red-500/20';
badge.textContent = 'Error';
cfApiIndicator.className = 'w-2 h-2 rounded-full bg-gray-300';
cfApiLatency.textContent = '—';
cfApiError.classList.add('hidden');
gqlApiIndicator.className = 'w-2 h-2 rounded-full bg-gray-300';
gqlApiLatency.textContent = '—';
gqlApiError.classList.add('hidden');
healthTimestamp.textContent = 'Check failed';
}
}
async function fetchMetrics() {
const output = document.getElementById('metrics-output');
const container = document.getElementById('metrics-container');
const count = document.getElementById('metrics-count');
const timestamp = document.getElementById('metrics-timestamp');
const indicator = document.getElementById('metrics-indicator');
const refreshIcon = document.getElementById('refresh-icon');
indicator.className = 'w-2 h-2 rounded-full bg-gray-300';
refreshIcon.classList.add('spin-ccw');
const scrollTop = container.scrollTop;
const minSpin = new Promise(r => setTimeout(r, 500));
try {
const [res] = await Promise.all([fetch('${metricsPath}'), minSpin]);
if (!res.ok) {
throw new Error('HTTP ' + res.status);
}
const text = await res.text();
output.textContent = text || '# No metrics available';
container.scrollTop = scrollTop;
const lines = text.split('\\n').filter(l => l && !l.startsWith('#'));
count.textContent = lines.length + ' metrics';
timestamp.textContent = 'Updated ' + new Date().toLocaleTimeString();
indicator.className = 'w-2 h-2 rounded-full bg-green-500 pulse-dot';
} catch (e) {
await minSpin;
output.textContent = '# Error fetching metrics: ' + e.message;
container.scrollTop = scrollTop;
count.textContent = '-';
timestamp.textContent = 'Failed';
indicator.className = 'w-2 h-2 rounded-full bg-red-500';
}
refreshIcon.classList.remove('spin-ccw');
}
// Initialize on page load
loadConfig();
checkHealth();
fetchMetrics();
setInterval(checkHealth, 10000);
setInterval(fetchMetrics, 10000);
</script>
`;
};

View File

@@ -0,0 +1,400 @@
import { DurableObject } from "cloudflare:workers";
import {
ACCOUNT_LEVEL_QUERIES,
getCloudflareMetricsClient,
ZONE_LEVEL_QUERIES,
} from "../cloudflare/client";
import { FREE_TIER_QUERIES } from "../cloudflare/queries";
import { filterZonesByIds, parseCommaSeparated } from "../lib/filters";
import { createLogger, type Logger } from "../lib/logger";
import type { MetricDefinition } from "../lib/metrics";
import { getConfig, type ResolvedConfig } from "../lib/runtime-config";
import { getTimeRange } from "../lib/time";
import type { Zone } from "../lib/types";
import { MetricExporter } from "./MetricExporter";
const STATE_KEY = "state";
// Account-scoped queries: all account-level + zone-batched (excludes zone-scoped REST queries)
const ACCOUNT_SCOPED_QUERIES = [
...ACCOUNT_LEVEL_QUERIES,
...ZONE_LEVEL_QUERIES.filter(
(q) => q !== "ssl-certificates" && q !== "lb-weight-metrics",
),
] as const;
// Zone-scoped REST queries (one DO per zone for parallelization and fault isolation)
const ZONE_SCOPED_QUERIES = ["ssl-certificates", "lb-weight-metrics"] as const;
type AccountMetricCoordinatorState = {
accountId: string;
accountName: string;
zones: Zone[];
totalZoneCount: number;
firewallRules: Record<string, string>;
lastZoneFetch: number;
lastRefresh: number;
};
/**
* Coordinates metric collection for a Cloudflare account and manages zone list caching and distributes work to MetricExporter DOs.
*/
export class AccountMetricCoordinator extends DurableObject<Env> {
private state: AccountMetricCoordinatorState | undefined;
constructor(ctx: DurableObjectState, env: Env) {
super(ctx, env);
ctx.blockConcurrencyWhile(async () => {
this.state =
await ctx.storage.get<AccountMetricCoordinatorState>(STATE_KEY);
});
}
/**
* Creates logger instance with account-specific tag.
*
* @param config Resolved runtime configuration.
* @returns Logger instance.
*/
private createLogger(config: ResolvedConfig): Logger {
const state = this.getState();
const tag = state.accountName.toLowerCase().replace(/[ -]/g, "_");
return createLogger("account_coordinator", {
format: config.logFormat,
level: config.logLevel,
}).child(tag);
}
/**
* Gets current coordinator state.
*
* @returns Current state.
* @throws {Error} When state not initialized.
*/
private getState(): AccountMetricCoordinatorState {
if (this.state === undefined) {
console.error(
"[account_coordinator] State not initialized - initialize() must be called first",
);
throw new Error("State not initialized");
}
return this.state;
}
/**
* Gets or creates coordinator stub for account and ensures coordinator is initialized before returning.
*
* @param accountId Cloudflare account ID.
* @param accountName Account display name for logging.
* @param env Worker environment bindings.
* @returns Initialized coordinator stub.
*/
static async get(accountId: string, accountName: string, env: Env) {
const stub = env.AccountMetricCoordinator.getByName(`account:${accountId}`);
await stub.initialize(accountId, accountName);
return stub;
}
/**
* Initializes coordinator state and starts alarm cycle. Idempotent safe to call multiple times.
*
* @param accountId Cloudflare account ID.
* @param accountName Account display name for logging.
*/
async initialize(accountId: string, accountName: string): Promise<void> {
if (this.state !== undefined) {
return;
}
const config = await getConfig(this.env);
this.state = {
accountId,
accountName,
zones: [],
totalZoneCount: 0,
firewallRules: {},
lastZoneFetch: 0,
lastRefresh: 0,
};
await this.ctx.storage.put(STATE_KEY, this.state);
await this.ctx.storage.setAlarm(
Date.now() + config.metricRefreshIntervalSeconds * 1000,
);
}
override async alarm(): Promise<void> {
const config = await getConfig(this.env);
const logger = this.createLogger(config);
logger.info("Alarm fired, refreshing zones");
await this.refresh(config, logger);
}
/**
* Refreshes zone list and pushes context to exporters. Exporters handle their own metric fetching via alarms.
*
* @param config Resolved runtime configuration.
* @param logger Logger instance.
*/
private async refresh(config: ResolvedConfig, logger: Logger): Promise<void> {
logger.info("Starting refresh");
try {
await this.refreshZonesAndPushContext(config, logger);
this.state = { ...this.getState(), lastRefresh: Date.now() };
await this.ctx.storage.put(STATE_KEY, this.state);
} catch (error) {
const msg = error instanceof Error ? error.message : String(error);
logger.error("Refresh failed", { error: msg });
}
await this.ctx.storage.setAlarm(
Date.now() + config.metricRefreshIntervalSeconds * 1000,
);
}
/**
* Refreshes zone list if stale then pushes context to all exporters.
*
* @param config Resolved runtime configuration.
* @param logger Logger instance.
*/
private async refreshZonesAndPushContext(
config: ResolvedConfig,
logger: Logger,
): Promise<void> {
const state = this.getState();
const ttlMs = config.zoneListCacheTtlSeconds * 1000;
const isStale = Date.now() - state.lastZoneFetch >= ttlMs;
// Calculate shared time range once for all exporters in this refresh cycle
const timeRange = getTimeRange(
config.scrapeDelaySeconds,
config.timeWindowSeconds,
);
let zones = state.zones;
let firewallRules = state.firewallRules;
if (isStale || zones.length === 0) {
const client = getCloudflareMetricsClient(this.env);
logger.info("Refreshing zones");
const allZones = await client.getZones(state.accountId);
// Apply zone whitelist if set
const cfZonesSet =
config.cfZones !== null ? parseCommaSeparated(config.cfZones) : null;
zones =
cfZonesSet !== null ? filterZonesByIds(allZones, cfZonesSet) : allZones;
// Build firewall rules map
firewallRules = {};
const rulesResults = await Promise.all(
zones.map((zone) =>
client.getFirewallRules(zone.id).catch((error) => {
const msg = error instanceof Error ? error.message : String(error);
logger.warn("Failed to fetch firewall rules", {
zone: zone.name,
error: msg,
});
return new Map<string, string>();
}),
),
);
for (const rules of rulesResults) {
for (const [id, name] of rules) {
firewallRules[id] = name;
}
}
this.state = {
...state,
zones,
totalZoneCount: allZones.length,
firewallRules,
lastZoneFetch: Date.now(),
};
await this.ctx.storage.put(STATE_KEY, this.state);
logger.info("Zones cached", {
total: allZones.length,
filtered: zones.length,
});
}
// Check if this account is marked as free tier
const cfFreeTierSet = parseCommaSeparated(config.cfFreeTierAccounts);
const isFreeTierAccount = cfFreeTierSet.has(state.accountId);
// Filter queries based on account tier
const accountQueries = isFreeTierAccount
? ACCOUNT_SCOPED_QUERIES.filter((q) =>
FREE_TIER_QUERIES.includes(q as (typeof FREE_TIER_QUERIES)[number]),
)
: ACCOUNT_SCOPED_QUERIES;
// Push zone context to account-scoped exporters AND initialize zone-scoped exporters concurrently
await Promise.all([
// Account-scoped exporters
...accountQueries.map(async (query) => {
try {
const exporter = await MetricExporter.get(
`account:${state.accountId}:${query}`,
this.env,
);
await exporter.updateZoneContext(
state.accountId,
state.accountName,
zones,
firewallRules,
timeRange,
);
} catch (error) {
const msg = error instanceof Error ? error.message : String(error);
logger.error("Failed to update zone context", {
query,
error: msg,
});
}
}),
// Zone-scoped exporters (skip for free tier accounts)
...(isFreeTierAccount
? []
: zones.flatMap((zone) =>
ZONE_SCOPED_QUERIES.map(async (query) => {
try {
const exporter = await MetricExporter.get(
`zone:${zone.id}:${query}`,
this.env,
);
await exporter.initializeZone(
zone,
state.accountId,
state.accountName,
timeRange,
);
} catch (error) {
const msg =
error instanceof Error ? error.message : String(error);
logger.error("Failed to initialize zone exporter", {
zone: zone.name,
query,
error: msg,
});
}
}),
)),
]);
logger.info("Context pushed to exporters", {
account_scoped: accountQueries.length,
zone_scoped: isFreeTierAccount
? 0
: zones.length * ZONE_SCOPED_QUERIES.length,
});
}
/**
* Collects and aggregates metrics from all MetricExporter DOs.
*
* @returns Metrics and zone counts.
*/
async export(): Promise<{
metrics: MetricDefinition[];
zoneCounts: { total: number; filtered: number; processed: number };
}> {
const config = await getConfig(this.env);
const logger = this.createLogger(config);
logger.info("Exporting metrics");
// Ensure exporters have been initialized
const staleThreshold = config.metricRefreshIntervalSeconds * 2 * 1000;
const initialState = this.getState();
if (
initialState.lastRefresh === 0 ||
Date.now() - initialState.lastRefresh > staleThreshold
) {
await this.refresh(config, logger);
}
// Re-get state after potential refresh (this.state may have been updated)
const state = this.getState();
// Check if this account is marked as free tier
const cfFreeTierSet = parseCommaSeparated(config.cfFreeTierAccounts);
const isFreeTierAccount = cfFreeTierSet.has(state.accountId);
// Filter queries based on account tier
const accountQueries = isFreeTierAccount
? ACCOUNT_SCOPED_QUERIES.filter((q) =>
FREE_TIER_QUERIES.includes(q as (typeof FREE_TIER_QUERIES)[number]),
)
: ACCOUNT_SCOPED_QUERIES;
// Collect from account-scoped exporters
const accountMetricsResults = await Promise.all(
accountQueries.map(async (query) => {
try {
const exporter = await MetricExporter.get(
`account:${state.accountId}:${query}`,
this.env,
);
return await exporter.export();
} catch (error) {
const msg = error instanceof Error ? error.message : String(error);
logger.error("Failed to export account metrics", {
query,
error: msg,
});
return [];
}
}),
);
// Collect from zone-scoped exporters (skip for free tier accounts)
const zoneMetricsResults = isFreeTierAccount
? []
: await Promise.all(
state.zones.flatMap((zone) =>
ZONE_SCOPED_QUERIES.map(async (query) => {
try {
const exporter = await MetricExporter.get(
`zone:${zone.id}:${query}`,
this.env,
);
return await exporter.export();
} catch (error) {
const msg =
error instanceof Error ? error.message : String(error);
logger.error("Failed to export zone metrics", {
zone: zone.name,
query,
error: msg,
});
return [];
}
}),
),
);
const allMetrics = [...accountMetricsResults, ...zoneMetricsResults].flat();
// Count processed zones (zones with at least one metric result)
const processedZones = zoneMetricsResults.filter(
(r) => r.length > 0,
).length;
return {
metrics: allMetrics,
zoneCounts: {
total: state.totalZoneCount,
filtered: state.zones.length,
processed: processedZones,
},
};
}
}

View File

@@ -0,0 +1,291 @@
import { DurableObject } from "cloudflare:workers";
import { getCloudflareMetricsClient } from "../cloudflare/client";
import { extractErrorInfo } from "../lib/errors";
import { filterAccountsByIds, parseCommaSeparated } from "../lib/filters";
import { createLogger, type Logger } from "../lib/logger";
import type { MetricDefinition } from "../lib/metrics";
import { serializeToPrometheus } from "../lib/prometheus";
import { getConfig, type ResolvedConfig } from "../lib/runtime-config";
import type { Account } from "../lib/types";
import { AccountMetricCoordinator } from "./AccountMetricCoordinator";
const STATE_KEY = "state";
type MetricCoordinatorState = {
identifier: string;
accounts: Account[];
lastAccountFetch: number;
};
/**
* Coordinates metrics collection across all Cloudflare accounts and maintains cached account list.
*/
export class MetricCoordinator extends DurableObject<Env> {
private state: MetricCoordinatorState | undefined;
/**
* Gets or creates singleton MetricCoordinator instance.
*
* @param env Worker environment bindings.
* @returns Initialized MetricCoordinator stub.
*/
static async get(env: Env) {
const stub = env.MetricCoordinator.getByName("metric-coordinator");
await stub.setIdentifier("metric-coordinator");
return stub;
}
/**
* Constructs MetricCoordinator and initializes state from storage.
*
* @param ctx Durable Object state.
* @param env Worker environment bindings.
*/
constructor(ctx: DurableObjectState, env: Env) {
super(ctx, env);
ctx.blockConcurrencyWhile(async () => {
this.state = await ctx.storage.get<MetricCoordinatorState>(STATE_KEY);
});
}
/**
* Creates logger instance with resolved configuration.
*
* @param config Resolved runtime configuration.
* @returns Logger instance.
*/
private createLogger(config: ResolvedConfig): Logger {
return createLogger("metric_coordinator", {
format: config.logFormat,
level: config.logLevel,
});
}
/**
* Initializes coordinator state if not already set.
*
* @param id Unique identifier for this coordinator instance.
*/
async setIdentifier(id: string): Promise<void> {
if (this.state !== undefined) {
return;
}
this.state = { identifier: id, accounts: [], lastAccountFetch: 0 };
await this.ctx.storage.put(STATE_KEY, this.state);
}
/**
* Gets coordinator state.
*
* @returns Current coordinator state.
* @throws {Error} When state not initialized.
*/
private getState(): MetricCoordinatorState {
if (this.state === undefined) {
throw new Error("State not initialized");
}
return this.state;
}
/**
* Refreshes accounts from Cloudflare API if cache expired.
*
* @param config Resolved runtime configuration.
* @param logger Logger instance.
* @returns Cached or refreshed account list.
*/
private async refreshAccountsIfStale(
config: ResolvedConfig,
logger: Logger,
): Promise<Account[]> {
const state = this.getState();
const ttlMs = config.accountListCacheTtlSeconds * 1000;
if (
state.accounts.length > 0 &&
Date.now() - state.lastAccountFetch < ttlMs
) {
return state.accounts;
}
const client = getCloudflareMetricsClient(this.env);
logger.info("Refreshing account list");
const allAccounts = await client.getAccounts();
// Filter accounts if whitelist is set
const cfAccountsSet =
config.cfAccounts !== null
? parseCommaSeparated(config.cfAccounts)
: null;
const accounts =
cfAccountsSet !== null
? filterAccountsByIds(allAccounts, cfAccountsSet)
: allAccounts;
this.state = {
...state,
accounts,
lastAccountFetch: Date.now(),
};
await this.ctx.storage.put(STATE_KEY, this.state);
logger.info("Accounts cached", {
total: allAccounts.length,
filtered: accounts.length,
});
return accounts;
}
/**
* Collects metrics from all accounts and serializes to Prometheus format.
*
* @returns Prometheus-formatted metrics string.
*/
async export(): Promise<string> {
const config = await getConfig(this.env);
const logger = this.createLogger(config);
logger.info("Collecting metrics");
const accounts = await this.refreshAccountsIfStale(config, logger);
if (accounts.length === 0) {
logger.warn("No accounts found");
return "";
}
logger.info("Exporting metrics", { account_count: accounts.length });
// Track errors by account and error code
const errorsByAccount: Map<string, { code: string; count: number }[]> =
new Map();
const results = await Promise.all(
accounts.map(async (account) => {
try {
const coordinator = await AccountMetricCoordinator.get(
account.id,
account.name,
this.env,
);
return await coordinator.export();
} catch (error) {
const info = extractErrorInfo(error);
logger.error("Failed to export account", {
account_id: account.id,
error_code: info.code,
error: info.message,
...(info.stack && { stack: info.stack }),
});
// Track error for metrics
const accountErrors = errorsByAccount.get(account.id) ?? [];
const existing = accountErrors.find((e) => e.code === info.code);
if (existing) {
existing.count++;
} else {
accountErrors.push({ code: info.code, count: 1 });
}
errorsByAccount.set(account.id, accountErrors);
return {
metrics: [],
zoneCounts: { total: 0, filtered: 0, processed: 0 },
};
}
}),
);
// Aggregate stats
const zoneCounts = { total: 0, filtered: 0, processed: 0 };
const allMetrics: MetricDefinition[] = [];
for (const result of results) {
allMetrics.push(...result.metrics);
zoneCounts.total += result.zoneCounts.total;
zoneCounts.filtered += result.zoneCounts.filtered;
zoneCounts.processed += result.zoneCounts.processed;
}
// Add exporter info metrics
const exporterMetrics = this.buildExporterInfoMetrics(
accounts.length,
zoneCounts,
errorsByAccount,
);
const metricsDenylist = parseCommaSeparated(config.metricsDenylist);
return serializeToPrometheus([...exporterMetrics, ...allMetrics], {
denylist: metricsDenylist,
excludeLabels: config.excludeHost ? new Set(["host"]) : undefined,
});
}
/**
* Builds exporter health and discovery metrics.
*
* @param accountCount Number of accounts discovered.
* @param zoneCounts Zone counts (total, filtered, processed).
* @param errorsByAccount Errors by account and error code.
* @returns Exporter info metrics.
*/
private buildExporterInfoMetrics(
accountCount: number,
zoneCounts: { total: number; filtered: number; processed: number },
errorsByAccount: Map<string, { code: string; count: number }[]>,
): MetricDefinition[] {
const metrics: MetricDefinition[] = [
{
name: "cloudflare_exporter_up",
help: "Exporter health",
type: "gauge",
values: [{ labels: {}, value: 1 }],
},
{
name: "cloudflare_accounts_total",
help: "Total accounts discovered",
type: "gauge",
values: [{ labels: {}, value: accountCount }],
},
{
name: "cloudflare_zones_total",
help: "Total zones before filtering",
type: "gauge",
values: [{ labels: {}, value: zoneCounts.total }],
},
{
name: "cloudflare_zones_filtered",
help: "Zones after whitelist filter",
type: "gauge",
values: [{ labels: {}, value: zoneCounts.filtered }],
},
{
name: "cloudflare_zones_processed",
help: "Zones successfully processed",
type: "gauge",
values: [{ labels: {}, value: zoneCounts.processed }],
},
];
// Add error metrics if any errors occurred
if (errorsByAccount.size > 0) {
const errorsMetric: MetricDefinition = {
name: "cloudflare_exporter_errors_total",
help: "Total errors during metric collection by account and error code",
type: "counter",
values: [],
};
for (const [accountId, errors] of errorsByAccount) {
for (const { code, count } of errors) {
errorsMetric.values.push({
labels: { account_id: accountId, error_code: code },
value: count,
});
}
}
metrics.push(errorsMetric);
}
return metrics;
}
}

View File

@@ -0,0 +1,495 @@
import { DurableObject } from "cloudflare:workers";
import {
getCloudflareMetricsClient,
isAccountLevelQuery,
isZoneLevelQuery,
} from "../cloudflare/client";
import { createLogger, type Logger } from "../lib/logger";
import type { MetricDefinition, MetricValue } from "../lib/metrics";
import { getConfig, type ResolvedConfig } from "../lib/runtime-config";
import { getTimeRange, metricKey } from "../lib/time";
import {
type CounterState,
MetricExporterIdSchema,
type MetricExporterIdString,
type TimeRange,
type Zone,
} from "../lib/types";
const STATE_KEY = "state";
type MetricExporterState = {
// Core identity
scopeType: "account" | "zone";
scopeId: string;
queryName: string;
// Metric storage
counters: Record<string, CounterState>;
metrics: MetricDefinition[];
lastIngest: number;
// Context for fetching (account-scoped)
accountId: string;
accountName: string;
zones: Zone[];
firewallRules: Record<string, string>;
// Context for fetching (zone-scoped)
zoneMetadata: Zone | null;
// Refresh state
refreshInterval: number;
lastRefresh: number;
lastError: string | null;
// SSL cert cache (zone-scoped only)
lastSslFetch: number;
};
/**
* Durable Object that fetches and exports Prometheus metrics for a specific query scope.
* Handles counter accumulation, alarm-based refresh scheduling, and metric caching.
*/
export class MetricExporter extends DurableObject<Env> {
private state: MetricExporterState | undefined;
constructor(ctx: DurableObjectState, env: Env) {
super(ctx, env);
ctx.blockConcurrencyWhile(async () => {
this.state = await ctx.storage.get<MetricExporterState>(STATE_KEY);
});
}
/**
* Create a logger instance with context from the exporter's state.
*
* @param config Resolved runtime configuration.
* @returns Logger instance with scope type, scope ID, and query name context.
*/
private createLogger(config: ResolvedConfig): Logger {
const state = this.getState();
return createLogger("metric_exporter", {
format: config.logFormat,
level: config.logLevel,
})
.child(state.scopeType)
.child(state.scopeId)
.child(state.queryName);
}
/**
* Get the current state or throw if not initialized.
*
* @returns Current state.
* @throws {Error} When state is undefined.
*/
private getState(): MetricExporterState {
if (this.state === undefined) {
console.error(
"State not initialized - initialize() must be called first",
);
throw new Error("State not initialized");
}
return this.state;
}
/**
* Get or create a MetricExporter instance by ID, ensuring it's initialized.
*
* @param id Composite ID in format "scopeType:scopeId:queryName".
* @param env Worker environment bindings.
* @returns Initialized MetricExporter stub.
*/
static async get(id: MetricExporterIdString, env: Env) {
const stub = env.MetricExporter.getByName(id);
await stub.initialize(id);
return stub;
}
/**
* Initialize the exporter state from a composite ID.
* Idempotent - skips if already initialized.
*
* @param id Composite ID string to parse into scope type, scope ID, and query name.
* @throws {ZodError} When ID format is invalid.
*/
async initialize(id: string): Promise<void> {
if (this.state !== undefined) {
return;
}
const config = await getConfig(this.env);
const parsed = MetricExporterIdSchema.parse(id);
this.state = {
scopeType: parsed.scopeType,
scopeId: parsed.scopeId,
queryName: parsed.queryName,
counters: {},
metrics: [],
lastIngest: 0,
accountId: "",
accountName: "",
zones: [],
firewallRules: {},
zoneMetadata: null,
refreshInterval: config.metricRefreshIntervalSeconds,
lastRefresh: 0,
lastError: null,
lastSslFetch: 0,
};
await this.ctx.storage.put(STATE_KEY, this.state);
}
/**
* Update zone context for account-scoped exporters.
* Called by AccountMetricCoordinator after zone list refresh.
* Triggers immediate fetch on first context push.
*
* @param accountId Cloudflare account ID.
* @param accountName Account display name.
* @param zones List of zones in the account.
* @param firewallRules Map of firewall rule IDs to descriptions.
* @param timeRange Shared time range for metrics queries.
*/
async updateZoneContext(
accountId: string,
accountName: string,
zones: Zone[],
firewallRules: Record<string, string>,
timeRange: TimeRange,
): Promise<void> {
const config = await getConfig(this.env);
const logger = this.createLogger(config);
const state = this.getState();
if (state.scopeType !== "account") {
logger.warn("updateZoneContext called on non-account exporter");
return;
}
const isFirstContext =
state.zones.length === 0 && zones.length > 0 && state.lastRefresh === 0;
this.state = {
...state,
accountId,
accountName,
zones,
firewallRules,
};
await this.ctx.storage.put(STATE_KEY, this.state);
logger.info("Zone context updated", { zone_count: zones.length });
// On first context push, fetch immediately then schedule recurring alarm
if (isFirstContext) {
await this.refreshWithTimeRange(timeRange, config, logger);
}
}
/**
* Initialize zone-scoped exporter with zone metadata.
* Called by AccountMetricCoordinator when ensuring zone exporters exist.
* Triggers immediate fetch on first initialization.
*
* @param zone Zone metadata including ID, name, and plan.
* @param accountId Cloudflare account ID that owns the zone.
* @param accountName Account display name.
* @param timeRange Shared time range for metrics queries.
*/
async initializeZone(
zone: Zone,
accountId: string,
accountName: string,
timeRange: TimeRange,
): Promise<void> {
const config = await getConfig(this.env);
const logger = this.createLogger(config);
const state = this.getState();
if (state.scopeType !== "zone") {
logger.warn("initializeZone called on non-zone exporter");
return;
}
const isFirstInit = state.zoneMetadata === null && state.lastRefresh === 0;
this.state = {
...state,
accountId,
accountName,
zoneMetadata: zone,
};
await this.ctx.storage.put(STATE_KEY, this.state);
logger.info("Zone metadata set", { zone: zone.name });
// On first init, fetch immediately then schedule recurring alarm
if (isFirstInit) {
await this.refreshWithTimeRange(timeRange, config, logger);
}
}
/**
* Durable Object alarm handler.
* Triggers metric refresh and reschedules next alarm with jitter.
*/
override async alarm(): Promise<void> {
const config = await getConfig(this.env);
const logger = this.createLogger(config);
logger.info("Alarm fired, refreshing");
const timeRange = getTimeRange(
config.scrapeDelaySeconds,
config.timeWindowSeconds,
);
await this.refreshWithTimeRange(timeRange, config, logger);
}
/**
* Public method for coordinator to trigger refresh with shared time range.
* Called by AccountMetricCoordinator to ensure all exporters use the same time window.
*
* @param timeRange Shared time range calculated by coordinator.
*/
async triggerRefresh(timeRange: TimeRange): Promise<void> {
const config = await getConfig(this.env);
const logger = this.createLogger(config);
await this.refreshWithTimeRange(timeRange, config, logger);
}
/**
* Refresh metrics from Cloudflare API using the provided time range.
* Handles account-scoped and zone-scoped queries, processes counters, and schedules next alarm.
*
* @param timeRange Time range for metrics queries.
* @param config Resolved runtime configuration.
* @param logger Logger instance for logging.
*/
private async refreshWithTimeRange(
timeRange: TimeRange,
config: ResolvedConfig,
logger: Logger,
): Promise<void> {
const state = this.getState();
// Skip if zone context not yet pushed (account-scoped needs zones)
if (state.scopeType === "account" && state.zones.length === 0) {
logger.info("Skipping refresh - no zone context yet");
await this.scheduleNextAlarm(config);
return;
}
// Skip if zone metadata not set (zone-scoped)
if (state.scopeType === "zone" && state.zoneMetadata === null) {
logger.info("Skipping refresh - no zone metadata yet");
await this.scheduleNextAlarm(config);
return;
}
// For zone-scoped (SSL certs), check cache TTL
if (state.scopeType === "zone") {
const cacheAgeMs = Date.now() - state.lastSslFetch;
const cacheTtlMs = config.sslCertsCacheTtlSeconds * 1000;
if (state.lastSslFetch > 0 && cacheAgeMs < cacheTtlMs) {
logger.debug("SSL cert cache fresh, skipping fetch", {
age_seconds: Math.floor(cacheAgeMs / 1000),
ttl_seconds: config.sslCertsCacheTtlSeconds,
});
await this.scheduleNextAlarm(config);
return;
}
}
const client = getCloudflareMetricsClient(this.env);
try {
let metrics: MetricDefinition[];
if (state.scopeType === "account") {
metrics = await this.fetchAccountScopedMetrics(
client,
state,
timeRange,
);
} else {
metrics = await this.fetchZoneScopedMetrics(client, state);
}
const processed = this.processCounters(metrics, state.counters);
this.state = {
...state,
metrics: processed.metrics,
counters: processed.counters,
lastRefresh: Date.now(),
lastSslFetch:
state.scopeType === "zone" ? Date.now() : state.lastSslFetch,
lastError: null,
};
await this.ctx.storage.put(STATE_KEY, this.state);
logger.info("Refresh complete", {
metric_count: metrics.length,
});
} catch (error) {
const msg = error instanceof Error ? error.message : String(error);
logger.error("Refresh failed", { error: msg });
this.state = { ...state, lastError: msg };
await this.ctx.storage.put(STATE_KEY, this.state);
}
await this.scheduleNextAlarm(config);
}
/**
* Schedule the next alarm with jitter for time range alignment.
*
* @param config Resolved runtime configuration.
*/
private async scheduleNextAlarm(config: ResolvedConfig): Promise<void> {
const intervalMs = config.metricRefreshIntervalSeconds * 1000;
// Jitter: 1-5s fixed (tighter clustering for time range alignment)
const jitter = 1000 + Math.random() * 4000;
const nextAlarm = Date.now() + intervalMs + jitter;
await this.ctx.storage.setAlarm(nextAlarm);
}
/**
* Fetch account-scoped metrics from Cloudflare API.
* Handles both account-level and zone-batched queries.
*
* @param client Cloudflare metrics client.
* @param state Current exporter state.
* @param timeRange Time range for metrics queries.
* @returns Array of metric definitions.
*/
private async fetchAccountScopedMetrics(
client: ReturnType<typeof getCloudflareMetricsClient>,
state: MetricExporterState,
timeRange: TimeRange,
): Promise<MetricDefinition[]> {
const { queryName, accountId, accountName, zones, firewallRules } = state;
// Account-level queries (worker-totals, logpush-account, magic-transit)
if (isAccountLevelQuery(queryName)) {
return client.getAccountMetrics(
queryName,
accountId,
accountName,
timeRange,
);
}
// Zone-batched queries - fetch all zones in one GraphQL call
if (isZoneLevelQuery(queryName)) {
const zoneIds = zones.map((z) => z.id);
return client.getZoneMetrics(
queryName,
zoneIds,
zones,
firewallRules,
timeRange,
);
}
// Unknown query - should not happen if IDs are constructed correctly
console.error("Unknown query type", { queryName });
return [];
}
/**
* Fetch zone-scoped metrics from Cloudflare API.
* Handles SSL certificates and load balancer weight metrics.
*
* @param client Cloudflare metrics client.
* @param state Current exporter state.
* @returns Array of metric definitions.
*/
private async fetchZoneScopedMetrics(
client: ReturnType<typeof getCloudflareMetricsClient>,
state: MetricExporterState,
): Promise<MetricDefinition[]> {
const { queryName, zoneMetadata } = state;
if (zoneMetadata === null) {
return [];
}
switch (queryName) {
case "ssl-certificates":
return client.getSSLCertificateMetricsForZone(zoneMetadata);
case "lb-weight-metrics":
return client.getLbWeightMetricsForZone(zoneMetadata);
default:
console.error("Unknown zone-scoped query", { queryName });
return [];
}
}
/**
* Return cached accumulated metrics.
*
* @returns Current snapshot of metrics with accumulated counter values.
*/
async export(): Promise<MetricDefinition[]> {
const state = this.getState();
return state.metrics;
}
/**
* Process raw metrics and accumulate counter values.
*
* @param rawMetrics Raw metrics from Cloudflare API.
* @param existingCounters Existing counter state.
* @returns Processed metrics with accumulated counter values and updated counter state.
*/
private processCounters(
rawMetrics: MetricDefinition[],
existingCounters: Record<string, CounterState>,
): { metrics: MetricDefinition[]; counters: Record<string, CounterState> } {
const newCounters: Record<string, CounterState> = { ...existingCounters };
const metrics = rawMetrics.map((metric) => {
if (metric.type !== "counter") {
return metric;
}
const processedValues: MetricValue[] = metric.values.map((value) => {
const key = metricKey(metric.name, value.labels);
newCounters[key] = this.updateCounter(newCounters[key], value.value);
return { labels: value.labels, value: newCounters[key].accumulated };
});
return { ...metric, values: processedValues };
});
return { metrics, counters: newCounters };
}
/**
* Update counter state with a new raw value.
* Handles counter resets by detecting decreases.
*
* @param existing Existing counter state or undefined for new counter.
* @param rawValue New raw counter value from API.
* @returns Updated counter state with accumulated value.
*/
private updateCounter(
existing: CounterState | undefined,
rawValue: number,
): CounterState {
if (!existing) {
return { prev: rawValue, accumulated: rawValue };
}
const delta =
rawValue < existing.prev ? rawValue : rawValue - existing.prev;
return {
prev: rawValue,
accumulated: existing.accumulated + delta,
};
}
}

5
src/env.d.ts vendored Normal file
View File

@@ -0,0 +1,5 @@
declare namespace Cloudflare {
interface Env {
CLOUDFLARE_API_TOKEN: string;
}
}

View File

@@ -1,64 +0,0 @@
import { DurableObject } from "cloudflare:workers";
/**
* Welcome to Cloudflare Workers! This is your first Durable Objects application.
*
* - Run `npm run dev` in your terminal to start a development server
* - Open a browser tab at http://localhost:8787/ to see your Durable Object in action
* - Run `npm run deploy` to publish your application
*
* Bind resources to your worker in `wrangler.jsonc`. After adding bindings, a type definition for the
* `Env` object can be regenerated with `npm run cf-typegen`.
*
* Learn more at https://developers.cloudflare.com/durable-objects
*/
/** A Durable Object's behavior is defined in an exported Javascript class */
export class MyDurableObject extends DurableObject<Env> {
/**
* The constructor is invoked once upon creation of the Durable Object, i.e. the first call to
* `DurableObjectStub::get` for a given identifier (no-op constructors can be omitted)
*
* @param ctx - The interface for interacting with Durable Object state
* @param env - The interface to reference bindings declared in wrangler.jsonc
*/
constructor(ctx: DurableObjectState, env: Env) {
super(ctx, env);
}
/**
* The Durable Object exposes an RPC method sayHello which will be invoked when a Durable
* Object instance receives a request from a Worker via the same method invocation on the stub
*
* @param name - The name provided to a Durable Object instance from a Worker
* @returns The greeting to be sent back to the Worker
*/
async sayHello(name: string): Promise<string> {
return `Hello, ${name}!`;
}
}
export default {
/**
* This is the standard fetch handler for a Cloudflare Worker
*
* @param request - The request submitted to the Worker from the client
* @param env - The interface to reference bindings declared in wrangler.jsonc
* @param ctx - The execution context of the Worker
* @returns The response to be sent back to the client
*/
async fetch(request, env, ctx): Promise<Response> {
// Create a stub to open a communication channel with the Durable Object
// instance named "foo".
//
// Requests from all Workers to the Durable Object instance named "foo"
// will go to a single remote Durable Object instance.
const stub = env.MY_DURABLE_OBJECT.getByName("foo");
// Call the `sayHello()` RPC method on the stub to invoke the method on
// the remote Durable Object instance.
const greeting = await stub.sayHello("world");
return new Response(greeting);
},
} satisfies ExportedHandler<Env>;

93
src/lib/config.ts Normal file
View File

@@ -0,0 +1,93 @@
import z from "zod";
/**
* Application configuration parsed from environment variables.
*/
export type AppConfig = {
readonly excludeHost: boolean;
readonly httpStatusGroup: boolean;
readonly metricsDenylist: ReadonlySet<string>;
readonly cfAccounts: ReadonlySet<string> | null;
readonly cfZones: ReadonlySet<string> | null;
readonly cfFreeTierAccounts: ReadonlySet<string>;
readonly metricsPath: string;
readonly disableUi: boolean;
readonly disableConfigApi: boolean;
};
/**
* Parses comma-separated string into Set, trimming whitespace.
* Returns empty Set for empty/undefined input.
*
* @param value Comma-separated string or undefined.
* @returns Set of trimmed non-empty strings.
*/
function parseCommaSeparated(value: string | undefined): Set<string> {
if (!value || value.trim() === "") {
return new Set();
}
return new Set(
value
.split(",")
.map((s) => s.trim())
.filter((s) => s.length > 0),
);
}
/**
* Optional environment variables not defined in wrangler.jsonc vars.
*/
type OptionalEnvVars = {
METRICS_DENYLIST?: string;
CF_ACCOUNTS?: string;
CF_ZONES?: string;
CF_FREE_TIER_ACCOUNTS?: string;
};
/**
* Parses application configuration from environment variables.
* Uses Zod for type coercion with sensible defaults.
*
* @param env Worker environment bindings.
* @returns Parsed application configuration.
*/
export function parseConfig(env: Env): AppConfig {
const optionalEnv = env as Env & OptionalEnvVars;
const excludeHost = z.coerce.boolean().catch(false).parse(env.EXCLUDE_HOST);
const httpStatusGroup = z.coerce
.boolean()
.catch(false)
.parse(env.CF_HTTP_STATUS_GROUP);
const metricsPath = z
.string()
.min(1)
.catch("/metrics")
.parse(env.METRICS_PATH);
const disableUi = z.coerce.boolean().catch(false).parse(env.DISABLE_UI);
const disableConfigApi = z.coerce
.boolean()
.catch(false)
.parse(env.DISABLE_CONFIG_API);
const metricsDenylist = parseCommaSeparated(optionalEnv.METRICS_DENYLIST);
const cfAccountsRaw = parseCommaSeparated(optionalEnv.CF_ACCOUNTS);
const cfAccounts = cfAccountsRaw.size > 0 ? cfAccountsRaw : null;
const cfZonesRaw = parseCommaSeparated(optionalEnv.CF_ZONES);
const cfZones = cfZonesRaw.size > 0 ? cfZonesRaw : null;
const cfFreeTierAccounts = parseCommaSeparated(
optionalEnv.CF_FREE_TIER_ACCOUNTS,
);
return {
excludeHost,
httpStatusGroup,
metricsDenylist,
cfAccounts,
cfZones,
cfFreeTierAccounts,
metricsPath,
disableUi,
disableConfigApi,
};
}

459
src/lib/errors.ts Normal file
View File

@@ -0,0 +1,459 @@
/**
* Error codes for categorization and alerting.
*/
export const ErrorCode = {
// API/Network
API_RATE_LIMITED: "API_RATE_LIMITED",
API_TIMEOUT: "API_TIMEOUT",
API_UNAVAILABLE: "API_UNAVAILABLE",
API_AUTH_FAILED: "API_AUTH_FAILED",
// GraphQL
GRAPHQL_ERROR: "GRAPHQL_ERROR",
GRAPHQL_FIELD_ACCESS: "GRAPHQL_FIELD_ACCESS",
// Config
CONFIG_INVALID: "CONFIG_INVALID",
CONFIG_PARSE_ERROR: "CONFIG_PARSE_ERROR",
// State
STATE_NOT_INITIALIZED: "STATE_NOT_INITIALIZED",
// Validation
VALIDATION_ERROR: "VALIDATION_ERROR",
// Timeout
TIMEOUT: "TIMEOUT",
// Unknown
UNKNOWN: "UNKNOWN",
} as const;
/**
* Error code type.
*/
export type ErrorCode = (typeof ErrorCode)[keyof typeof ErrorCode];
/**
* Error context type.
*/
type ErrorContext = Record<string, unknown>;
/**
* CloudflarePrometheusError options.
*/
type CloudflarePrometheusErrorOptions = ErrorOptions & {
context?: ErrorContext;
retryable?: boolean;
};
/**
* Base error class with cause chaining, error codes, and structured logging support.
*/
export class CloudflarePrometheusError extends Error {
readonly code: ErrorCode;
readonly context: ErrorContext;
readonly timestamp: string;
readonly retryable: boolean;
/**
* Create a CloudflarePrometheusError.
*
* @param message Error message.
* @param code Error code.
* @param options Error options.
*/
constructor(
message: string,
code: ErrorCode,
options?: CloudflarePrometheusErrorOptions,
) {
super(message, options);
// Fix prototype chain for instanceof checks
Object.setPrototypeOf(this, new.target.prototype);
this.name = this.constructor.name;
this.code = code;
this.context = options?.context ?? {};
this.timestamp = new Date().toISOString();
this.retryable = options?.retryable ?? false;
// Append cause stack if available
if (options?.cause instanceof Error) {
this.stack = `${this.stack}\nCaused by: ${options.cause.stack}`;
}
}
/**
* Convert to structured data for logging.
*
* @returns Structured error context.
*/
toStructuredData(): ErrorContext {
return {
error_code: this.code,
error_message: this.message,
error_name: this.name,
error_retryable: this.retryable,
...this.context,
};
}
}
/**
* API errors (rate limits, unavailable, auth failures).
*/
export class ApiError extends CloudflarePrometheusError {
readonly statusCode?: number;
/**
* Create an ApiError.
*
* @param message Error message.
* @param options Error options.
*/
constructor(
message: string,
options?: CloudflarePrometheusErrorOptions & { statusCode?: number },
) {
const statusCode = options?.statusCode;
let code: ErrorCode;
let retryable = false;
if (statusCode === 429) {
code = ErrorCode.API_RATE_LIMITED;
retryable = true;
} else if (statusCode === 401 || statusCode === 403) {
code = ErrorCode.API_AUTH_FAILED;
} else if (statusCode !== undefined && statusCode >= 500) {
code = ErrorCode.API_UNAVAILABLE;
retryable = true;
} else {
code = ErrorCode.API_UNAVAILABLE;
}
super(message, code, { ...options, retryable });
this.statusCode = statusCode;
}
/**
* Convert to structured data for logging.
*
* @returns Structured error context.
*/
override toStructuredData(): ErrorContext {
return {
...super.toStructuredData(),
...(this.statusCode !== undefined && { status_code: this.statusCode }),
};
}
}
/**
* GraphQL error detail.
*/
type GraphQLErrorDetail = {
message: string;
path?: ReadonlyArray<string | number>;
extensions?: Record<string, unknown>;
};
/**
* GraphQL query errors with access to underlying error details.
*/
export class GraphQLError extends CloudflarePrometheusError {
readonly graphqlErrors: GraphQLErrorDetail[];
/**
* Create a GraphQLError.
*
* @param message Error message.
* @param graphqlErrors GraphQL error details.
* @param options Error options.
*/
constructor(
message: string,
graphqlErrors: GraphQLErrorDetail[] = [],
options?: CloudflarePrometheusErrorOptions,
) {
const hasFieldAccessError = graphqlErrors.some(
(e) =>
e.message.includes("does not have access") ||
e.extensions?.code === "FORBIDDEN",
);
const code = hasFieldAccessError
? ErrorCode.GRAPHQL_FIELD_ACCESS
: ErrorCode.GRAPHQL_ERROR;
super(message, code, options);
this.graphqlErrors = graphqlErrors;
}
/**
* Convert to structured data for logging.
*
* @returns Structured error context.
*/
override toStructuredData(): ErrorContext {
return {
...super.toStructuredData(),
graphql_error_count: this.graphqlErrors.length,
graphql_paths: this.graphqlErrors
.map((e) => e.path?.join("."))
.filter(Boolean),
};
}
}
/**
* Configuration parsing/validation errors.
*/
export class ConfigError extends CloudflarePrometheusError {
readonly issues?: Array<{ path: string; message: string }>;
/**
* Create a ConfigError.
*
* @param message Error message.
* @param options Error options.
*/
constructor(
message: string,
options?: CloudflarePrometheusErrorOptions & {
issues?: Array<{ path: string; message: string }>;
},
) {
const code = message.includes("parse")
? ErrorCode.CONFIG_PARSE_ERROR
: ErrorCode.CONFIG_INVALID;
super(message, code, options);
this.issues = options?.issues;
}
/**
* Convert to structured data for logging.
*
* @returns Structured error context.
*/
override toStructuredData(): ErrorContext {
return {
...super.toStructuredData(),
...(this.issues && { validation_issues: this.issues }),
};
}
}
/**
* State not initialized (DO not ready).
*/
export class StateNotInitializedError extends CloudflarePrometheusError {
/**
* Create a StateNotInitializedError.
*
* @param component Component name.
* @param options Error options.
*/
constructor(
component: string,
options?: Omit<CloudflarePrometheusErrorOptions, "context">,
) {
super(
`State not initialized - initialize() must be called first`,
ErrorCode.STATE_NOT_INITIALIZED,
{
...options,
context: { component },
},
);
}
}
/**
* Operation timeout.
*/
export class TimeoutError extends CloudflarePrometheusError {
readonly timeoutMs: number;
readonly operation: string;
/**
* Create a TimeoutError.
*
* @param operation Operation name.
* @param timeoutMs Timeout in milliseconds.
* @param options Error options.
*/
constructor(
operation: string,
timeoutMs: number,
options?: CloudflarePrometheusErrorOptions,
) {
super(`${operation} timed out after ${timeoutMs}ms`, ErrorCode.TIMEOUT, {
...options,
retryable: true,
context: { ...options?.context, operation, timeout_ms: timeoutMs },
});
this.timeoutMs = timeoutMs;
this.operation = operation;
}
}
/**
* Race promise against timeout with proper cleanup.
*
* @param promise Promise to race.
* @param ms Timeout in milliseconds.
* @param operation Operation name.
* @returns Discriminated union for type-safe handling.
*/
export async function withTimeout<T>(
promise: Promise<T>,
ms: number,
operation = "Operation",
): Promise<{ ok: true; value: T } | { ok: false; error: TimeoutError }> {
let timeoutId: ReturnType<typeof setTimeout> | undefined;
const timeoutPromise = new Promise<never>((_, reject) => {
timeoutId = setTimeout(() => reject(new TimeoutError(operation, ms)), ms);
});
try {
const value = await Promise.race([promise, timeoutPromise]);
return { ok: true, value };
} catch (err) {
if (err instanceof TimeoutError) {
return { ok: false, error: err };
}
throw err;
} finally {
if (timeoutId !== undefined) {
clearTimeout(timeoutId);
}
}
}
/**
* Validation error (Zod or other).
*/
export class ValidationError extends CloudflarePrometheusError {
readonly field?: string;
/**
* Create a ValidationError.
*
* @param message Error message.
* @param options Error options.
*/
constructor(
message: string,
options?: CloudflarePrometheusErrorOptions & { field?: string },
) {
super(message, ErrorCode.VALIDATION_ERROR, options);
this.field = options?.field;
}
/**
* Convert to structured data for logging.
*
* @returns Structured error context.
*/
override toStructuredData(): ErrorContext {
return {
...super.toStructuredData(),
...(this.field && { field: this.field }),
};
}
}
/**
* Extract structured error info from any error type.
*
* @param error Error to extract info from.
* @returns Structured error info.
*/
export function extractErrorInfo(error: unknown): {
message: string;
stack?: string;
code: ErrorCode;
context: ErrorContext;
retryable: boolean;
} {
if (error instanceof CloudflarePrometheusError) {
return {
message: error.message,
stack: error.stack,
code: error.code,
context: error.context,
retryable: error.retryable,
};
}
if (error instanceof Error) {
return {
message: error.message,
stack: error.stack,
code: ErrorCode.UNKNOWN,
context: {},
retryable: false,
};
}
return {
message: String(error),
code: ErrorCode.UNKNOWN,
context: {},
retryable: false,
};
}
/**
* Check if an error is retryable.
*
* @param error Error to check.
* @returns True if retryable.
*/
export function isRetryable(error: unknown): boolean {
if (error instanceof CloudflarePrometheusError) {
return error.retryable;
}
// Network errors are generally retryable
if (error instanceof TypeError && error.message.includes("fetch")) {
return true;
}
return false;
}
/**
* Wrap an unknown error as a CloudflarePrometheusError.
*
* @param error Error to wrap.
* @param message Error message.
* @param code Error code.
* @param context Error context.
* @returns Wrapped error.
*/
export function wrapError(
error: unknown,
message: string,
code: ErrorCode = ErrorCode.UNKNOWN,
context?: ErrorContext,
): CloudflarePrometheusError {
if (error instanceof CloudflarePrometheusError) {
// Already our error type, just add context if needed
if (context) {
return new CloudflarePrometheusError(message, error.code, {
cause: error,
context: { ...error.context, ...context },
retryable: error.retryable,
});
}
return error;
}
return new CloudflarePrometheusError(message, code, {
cause: error instanceof Error ? error : undefined,
context,
});
}

58
src/lib/filters.ts Normal file
View File

@@ -0,0 +1,58 @@
import type { Account, Zone } from "./types";
/**
* Parses comma-separated string into Set, trimming whitespace.
*
* @param value Comma-separated string to parse.
* @returns Set of trimmed non-empty strings, or empty Set for empty/undefined input.
*/
export function parseCommaSeparated(value: string | undefined): Set<string> {
if (!value || value.trim() === "") {
return new Set();
}
return new Set(
value
.split(",")
.map((s) => s.trim())
.filter((s) => s.length > 0),
);
}
/**
* Filters accounts to only include those with IDs in the set.
*
* @param accounts Array of accounts to filter.
* @param includeIds Set of account IDs to include.
* @returns Filtered array of accounts.
*/
export function filterAccountsByIds(
accounts: Account[],
includeIds: ReadonlySet<string>,
): Account[] {
return accounts.filter((a) => includeIds.has(a.id));
}
/**
* Filters zones to only include those with IDs in the set.
*
* @param zones Array of zones to filter.
* @param includeIds Set of zone IDs to include.
* @returns Filtered array of zones.
*/
export function filterZonesByIds(
zones: Zone[],
includeIds: ReadonlySet<string>,
): Zone[] {
return zones.filter((z) => includeIds.has(z.id));
}
/**
* Looks up zone name by ID, falling back to ID if not found.
*
* @param zoneId Zone ID to look up.
* @param zones Array of zones to search.
* @returns Zone name if found, otherwise the zone ID.
*/
export function findZoneName(zoneId: string, zones: Zone[]): string {
return zones.find((z) => z.id === zoneId)?.name ?? zoneId;
}

185
src/lib/health.ts Normal file
View File

@@ -0,0 +1,185 @@
import {
CLOUDFLARE_GQL_URL,
getCloudflareMetricsClient,
} from "../cloudflare/client";
import { extractErrorInfo, withTimeout } from "./errors";
import { getConfig } from "./runtime-config";
const CHECK_TIMEOUT_MS = 5_000;
type CheckStatus = "healthy" | "unhealthy";
type HealthCheck = {
status: CheckStatus;
latency_ms: number;
error?: string;
error_code?: string;
};
type HealthResponse = {
status: CheckStatus;
timestamp: string;
checks: {
cloudflare_api: HealthCheck;
graphql_api: HealthCheck;
};
};
type CachedHealth = {
response: HealthResponse;
expires: number;
};
let healthCache: CachedHealth | null = null;
/**
* Check Cloudflare REST API connectivity by fetching accounts.
*
* @param env Environment variables.
* @returns Health check result.
*/
async function checkCloudflareApi(env: Env): Promise<HealthCheck> {
const start = performance.now();
try {
const client = getCloudflareMetricsClient(env);
const result = await withTimeout(
client.getAccounts(),
CHECK_TIMEOUT_MS,
"Cloudflare API health check",
);
const latency_ms = Math.round(performance.now() - start);
if (result.ok) {
return { status: "healthy", latency_ms };
}
return {
status: "unhealthy",
latency_ms,
error: result.error.message,
error_code: result.error.code,
};
} catch (err) {
const latency_ms = Math.round(performance.now() - start);
const info = extractErrorInfo(err);
return {
status: "unhealthy",
latency_ms,
error: info.message,
error_code: info.code,
};
}
}
/**
* Check Cloudflare GraphQL API connectivity via introspection.
*
* @param env Environment variables.
* @returns Health check result.
*/
async function checkGraphqlApi(env: Env): Promise<HealthCheck> {
const start = performance.now();
try {
const result = await withTimeout(
fetch(CLOUDFLARE_GQL_URL, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${env.CLOUDFLARE_API_TOKEN}`,
},
body: JSON.stringify({
query: "{ __typename }",
}),
}),
CHECK_TIMEOUT_MS,
"GraphQL API health check",
);
const latency_ms = Math.round(performance.now() - start);
if (!result.ok) {
return {
status: "unhealthy",
latency_ms,
error: result.error.message,
error_code: result.error.code,
};
}
const response = result.value;
if (!response.ok) {
return {
status: "unhealthy",
latency_ms,
error: `HTTP ${response.status}`,
error_code: "API_UNAVAILABLE",
};
}
return { status: "healthy", latency_ms };
} catch (err) {
const latency_ms = Math.round(performance.now() - start);
const info = extractErrorInfo(err);
return {
status: "unhealthy",
latency_ms,
error: info.message,
error_code: info.code,
};
}
}
/**
* Perform health check with configurable caching.
*
* @param env Environment variables.
* @returns Health check response.
*/
export async function checkHealth(env: Env): Promise<HealthResponse> {
const now = Date.now();
const config = await getConfig(env);
const cacheTtlMs = config.healthCheckCacheTtlSeconds * 1000;
if (healthCache && healthCache.expires > now) {
return healthCache.response;
}
const [cloudflareApi, graphqlApi] = await Promise.all([
checkCloudflareApi(env),
checkGraphqlApi(env),
]);
const allHealthy =
cloudflareApi.status === "healthy" && graphqlApi.status === "healthy";
const response: HealthResponse = {
status: allHealthy ? "healthy" : "unhealthy",
timestamp: new Date().toISOString(),
checks: {
cloudflare_api: cloudflareApi,
graphql_api: graphqlApi,
},
};
healthCache = {
response,
expires: now + cacheTtlMs,
};
return response;
}
/**
* Build HTTP response from health check result.
*
* @param health Health check response.
* @returns HTTP response with JSON body.
*/
export function healthResponse(health: HealthResponse): Response {
const status = health.status === "healthy" ? 200 : 503;
return new Response(JSON.stringify(health), {
status,
headers: { "Content-Type": "application/json" },
});
}

323
src/lib/logger.ts Normal file
View File

@@ -0,0 +1,323 @@
import { createConsola, type LogObject } from "consola";
// Raw ANSI escape codes - bypass consola's color detection which doesn't work in wrangler dev
const ansi = {
reset: "\x1b[0m",
dim: "\x1b[2m",
bold: "\x1b[1m",
red: "\x1b[31m",
green: "\x1b[32m",
yellow: "\x1b[33m",
blue: "\x1b[34m",
magenta: "\x1b[35m",
cyan: "\x1b[36m",
white: "\x1b[37m",
gray: "\x1b[90m",
};
const c = {
dim: (s: string) => `${ansi.dim}${s}${ansi.reset}`,
red: (s: string) => `${ansi.red}${s}${ansi.reset}`,
green: (s: string) => `${ansi.green}${s}${ansi.reset}`,
yellow: (s: string) => `${ansi.yellow}${s}${ansi.reset}`,
cyan: (s: string) => `${ansi.cyan}${s}${ansi.reset}`,
white: (s: string) => `${ansi.white}${s}${ansi.reset}`,
gray: (s: string) => `${ansi.gray}${s}${ansi.reset}`,
magenta: (s: string) => `${ansi.magenta}${s}${ansi.reset}`,
};
/**
* Log severity levels.
*/
export type LogLevel = "debug" | "info" | "warn" | "error";
/**
* Output format: json for structured logs, pretty for human-readable.
*/
export type LogFormat = "json" | "pretty";
/**
* Key-value pairs attached to log entries.
*/
export type StructuredData = Record<string, unknown>;
/**
* Structured logger with level methods, namespacing, and context.
*/
export interface Logger {
/**
* Log debug message.
*
* @param msg Message text.
* @param data Optional structured data.
*/
debug(msg: string, data?: StructuredData): void;
/**
* Log info message.
*
* @param msg Message text.
* @param data Optional structured data.
*/
info(msg: string, data?: StructuredData): void;
/**
* Log warning message.
*
* @param msg Message text.
* @param data Optional structured data.
*/
warn(msg: string, data?: StructuredData): void;
/**
* Log error message.
*
* @param msg Message text.
* @param data Optional structured data.
*/
error(msg: string, data?: StructuredData): void;
/**
* Create child logger with namespaced tag.
*
* @param namespace Namespace appended to parent tag with colon separator.
* @returns New logger instance.
*/
child(namespace: string): Logger;
/**
* Create logger with merged context data.
*
* @param ctx Context data merged into all log entries.
* @returns New logger instance.
*/
withContext(ctx: StructuredData): Logger;
}
/**
* Logger configuration.
*/
export interface LoggerConfig {
/** Output format, defaults to pretty. */
format?: LogFormat;
/** Minimum log level, defaults to info. */
level?: LogLevel;
}
const LEVELS: Record<LogLevel, number> = {
debug: 0,
info: 1,
warn: 2,
error: 3,
};
const LEVEL_COLORS: Record<LogLevel, (s: string) => string> = {
debug: c.gray,
info: c.cyan,
warn: c.yellow,
error: c.red,
};
const LEVEL_ICONS: Record<LogLevel, string> = {
debug: "●",
info: "◆",
warn: "▲",
error: "✖",
};
/**
* Format current time as HH:MM:SS.
*
* @returns Formatted time string.
*/
function formatTime(): string {
const now = new Date();
return `${String(now.getHours()).padStart(2, "0")}:${String(now.getMinutes()).padStart(2, "0")}:${String(now.getSeconds()).padStart(2, "0")}`;
}
/**
* Get current UTC timestamp in ISO format.
*
* @returns ISO 8601 timestamp string.
*/
function utcTimestamp(): string {
return new Date().toISOString();
}
/**
* Format value for display in logs.
*
* @param v Value to format.
* @returns Formatted string representation.
*/
function formatValue(v: unknown): string {
if (typeof v === "string") return v;
if (typeof v === "number" || typeof v === "boolean") return String(v);
return JSON.stringify(v);
}
/**
* Format structured data as key=value pairs.
*
* @param data Structured data object.
* @returns Formatted string with colored key-value pairs.
*/
function formatData(data: StructuredData): string {
return Object.entries(data)
.map(([k, v]) => `${c.dim(k)}=${c.white(formatValue(v))}`)
.join(" ");
}
/**
* Shorten tag for display: truncate zone/account IDs to 8 chars.
*
* @param tag Tag string to shorten.
* @returns Shortened tag string.
*/
function shortenTag(tag: string): string {
// Pattern: something:scope:longid:query -> something:scope:shortid:query
return tag.replace(/([a-f0-9]{32})/g, (match) => match.slice(0, 8));
}
/**
* Create pretty console reporter for human-readable logs.
*
* @param minLevel Minimum log level to output.
* @returns Reporter object with log method.
*/
function createPrettyReporter(minLevel: LogLevel) {
const minLevelNum = LEVELS[minLevel];
return {
log(logObj: LogObject) {
const level = logObj.type as LogLevel;
if (LEVELS[level] === undefined || LEVELS[level] < minLevelNum) return;
const tag = logObj.tag || "app";
const colorFn = LEVEL_COLORS[level] || c.white;
const icon = LEVEL_ICONS[level] || "●";
const args = logObj.args as [string, StructuredData?];
const msg = args[0];
const data = args[1];
const time = c.dim(formatTime());
const levelBadge = colorFn(`${icon} ${level.toUpperCase().padEnd(5)}`);
const shortTag = c.dim(shortenTag(tag));
const suffix = data ? ` ${formatData(data)}` : "";
console.log(`${time} ${levelBadge} ${shortTag} ${msg}${suffix}`);
},
};
}
/**
* Create JSON reporter for structured logs.
*
* @param minLevel Minimum log level to output.
* @returns Reporter object with log method.
*/
function createJsonReporter(minLevel: LogLevel) {
const minLevelNum = LEVELS[minLevel];
return {
log(logObj: LogObject) {
const level = logObj.type as LogLevel;
if (LEVELS[level] === undefined || LEVELS[level] < minLevelNum) return;
const tagParts = (logObj.tag || "app").split(":");
const [logger, ...namespaceParts] = tagParts;
const namespace =
namespaceParts.length > 0 ? namespaceParts.join(":") : undefined;
const args = logObj.args as [string, StructuredData?];
const msg = args[0];
const data = args[1];
console.log(
JSON.stringify({
ts: utcTimestamp(),
logger,
...(namespace && { namespace }),
level,
msg,
...data,
}),
);
},
};
}
// Consola log levels: 0=silent, 1=error, 2=warn, 3=info, 4=debug, 5=trace
const CONSOLA_LEVELS: Record<LogLevel, number> = {
error: 1,
warn: 2,
info: 3,
debug: 4,
};
/**
* Create logger instance with specified name and config.
*
* @param name Logger name, normalized to lowercase with underscores.
* @param config Logger configuration.
* @returns Configured logger instance.
*/
export function createLogger(name: string, config: LoggerConfig = {}): Logger {
const format = config.format ?? "pretty";
const level = config.level ?? "info";
const reporter =
format === "json" ? createJsonReporter(level) : createPrettyReporter(level);
const consola = createConsola({
level: CONSOLA_LEVELS[level],
reporters: [reporter],
});
function makeLogger(tag: string, baseContext: StructuredData = {}): Logger {
const instance = consola.withTag(tag);
const mergeData = (data?: StructuredData): StructuredData | undefined => {
if (!data && Object.keys(baseContext).length === 0) return undefined;
if (!data) return baseContext;
return { ...baseContext, ...data };
};
return {
debug: (msg, data) => instance.debug(msg, mergeData(data)),
info: (msg, data) => instance.info(msg, mergeData(data)),
warn: (msg, data) => instance.warn(msg, mergeData(data)),
error: (msg, data) => instance.error(msg, mergeData(data)),
child: (ns) => makeLogger(`${tag}:${ns}`, baseContext),
withContext: (ctx) => makeLogger(tag, { ...baseContext, ...ctx }),
};
}
const normalizedName = name.toLowerCase().replace(/[ -]/g, "_");
return makeLogger(normalizedName);
}
/**
* Create logger config from Cloudflare Worker env.
*
* @param env Environment object with LOG_FORMAT and LOG_LEVEL.
* @returns Logger configuration.
*/
export function configFromEnv(env: {
LOG_FORMAT?: string;
LOG_LEVEL?: string;
}): LoggerConfig {
const format = env.LOG_FORMAT;
const level = env.LOG_LEVEL;
return {
format: format === "json" || format === "pretty" ? format : "pretty",
level:
level === "debug" ||
level === "info" ||
level === "warn" ||
level === "error"
? level
: "info",
};
}

42
src/lib/metrics.ts Normal file
View File

@@ -0,0 +1,42 @@
import z from "zod";
/**
* Prometheus metric type discriminator.
*/
export type MetricType = z.infer<typeof MetricTypeSchema>;
/**
* Zod schema validating Prometheus metric types (counter or gauge).
*/
export const MetricTypeSchema = z.union([
z.literal("counter"),
z.literal("gauge"),
]);
/**
* Single metric observation with labels and numeric value.
*/
export type MetricValue = z.infer<typeof MetricValueSchema>;
/**
* Zod schema validating metric observations with label key-value pairs and numeric values.
*/
export const MetricValueSchema = z.object({
labels: z.record(z.string(), z.string()),
value: z.number(),
});
/**
* Complete metric definition with metadata and observations for Prometheus export.
*/
export type MetricDefinition = z.infer<typeof MetricDefinitionSchema>;
/**
* Zod schema validating complete metric definitions including name, help text, type, and observations.
*/
export const MetricDefinitionSchema = z.object({
name: z.string(),
help: z.string(),
type: MetricTypeSchema,
values: z.array(MetricValueSchema),
});

149
src/lib/prometheus.ts Normal file
View File

@@ -0,0 +1,149 @@
import type { MetricDefinition } from "./metrics";
/**
* Options for Prometheus serialization.
*/
export type SerializeOptions = {
/** Set of metric names to exclude from output. */
denylist?: ReadonlySet<string>;
/** Set of label keys to exclude from all metrics. */
excludeLabels?: ReadonlySet<string>;
};
/**
* Serializes MetricDefinition array to Prometheus text exposition format.
* Groups metrics by name, outputs HELP/TYPE headers, then values.
*
* @param metrics Array of metric definitions to serialize.
* @param options Serialization options for filtering.
* @returns Prometheus-formatted metrics string.
*/
export function serializeToPrometheus(
metrics: readonly MetricDefinition[],
options?: SerializeOptions,
): string {
const denylist = options?.denylist ?? new Set<string>();
const excludeLabels = options?.excludeLabels ?? new Set<string>();
// Group metrics by name to consolidate HELP/TYPE headers
const grouped = new Map<string, MetricDefinition>();
for (const metric of metrics) {
// Skip denied metrics
if (denylist.has(metric.name)) {
continue;
}
// Filter excluded labels from all values
const filteredValues =
excludeLabels.size > 0
? metric.values.map((v) => ({
...v,
labels: filterLabels(v.labels, excludeLabels),
}))
: metric.values;
const existing = grouped.get(metric.name);
if (existing) {
// Merge values
grouped.set(metric.name, {
...existing,
values: [...existing.values, ...filteredValues],
});
} else {
grouped.set(metric.name, { ...metric, values: [...filteredValues] });
}
}
const lines: string[] = [];
for (const [name, metric] of grouped) {
// HELP line
lines.push(`# HELP ${name} ${escapeHelp(metric.help)}`);
// TYPE line
lines.push(`# TYPE ${name} ${metric.type}`);
// Value lines
for (const { labels, value } of metric.values) {
const labelStr = formatLabels(labels);
lines.push(`${name}${labelStr} ${formatValue(value)}`);
}
// Blank line between metrics for readability
lines.push("");
}
return lines.join("\n");
}
/**
* Filters out excluded label keys from a labels object.
*
* @param labels Original label key-value pairs.
* @param exclude Set of label keys to exclude.
* @returns Filtered labels object.
*/
function filterLabels(
labels: Record<string, string>,
exclude: ReadonlySet<string>,
): Record<string, string> {
const filtered: Record<string, string> = {};
for (const [key, value] of Object.entries(labels)) {
if (!exclude.has(key)) {
filtered[key] = value;
}
}
return filtered;
}
/**
* Formats labels object into Prometheus label string.
*
* @param labels Label key-value pairs.
* @returns Formatted label string like `{key="value"}` or empty string.
*/
function formatLabels(labels: Record<string, string>): string {
const entries = Object.entries(labels);
if (entries.length === 0) return "";
const formatted = entries
.map(([key, value]) => `${key}="${escapeLabel(value)}"`)
.join(",");
return `{${formatted}}`;
}
/**
* Formats numeric value for Prometheus output.
*
* @param value Numeric value to format.
* @returns String representation handling NaN and Infinity.
*/
function formatValue(value: number): string {
if (Number.isNaN(value)) return "NaN";
if (!Number.isFinite(value)) return value > 0 ? "+Inf" : "-Inf";
return String(value);
}
/**
* Escapes special characters in HELP text.
*
* @param help Raw help text.
* @returns Escaped help text.
*/
function escapeHelp(help: string): string {
return help.replace(/\\/g, "\\\\").replace(/\n/g, "\\n");
}
/**
* Escapes special characters in label values.
*
* @param value Raw label value.
* @returns Escaped label value.
*/
function escapeLabel(value: string): string {
return value
.replace(/\\/g, "\\\\")
.replace(/"/g, '\\"')
.replace(/\n/g, "\\n");
}

375
src/lib/runtime-config.ts Normal file
View File

@@ -0,0 +1,375 @@
import { z } from "zod";
/** KV storage key for configuration overrides. */
const KV_KEY = "overrides";
/**
* Zod schema for valid configuration key names.
*/
export const ConfigKeySchema = z.enum([
// Timing/limits
"queryLimit",
"scrapeDelaySeconds",
"timeWindowSeconds",
"metricRefreshIntervalSeconds",
// Cache TTLs
"accountListCacheTtlSeconds",
"zoneListCacheTtlSeconds",
"sslCertsCacheTtlSeconds",
"healthCheckCacheTtlSeconds",
// Logging
"logFormat",
"logLevel",
// Filters/whitelists
"cfAccounts",
"cfZones",
"cfFreeTierAccounts",
"metricsDenylist",
// Output options
"excludeHost",
"httpStatusGroup",
]);
/**
* Union type of all valid configuration key names.
*/
export type ConfigKey = z.infer<typeof ConfigKeySchema>;
/**
* Zod schemas for individual configuration values by key.
*/
const ConfigValueSchemas = {
queryLimit: z.number().int().positive(),
scrapeDelaySeconds: z.number().int().nonnegative(),
timeWindowSeconds: z.number().int().positive(),
metricRefreshIntervalSeconds: z.number().int().positive(),
accountListCacheTtlSeconds: z.number().int().nonnegative(),
zoneListCacheTtlSeconds: z.number().int().nonnegative(),
sslCertsCacheTtlSeconds: z.number().int().nonnegative(),
healthCheckCacheTtlSeconds: z.number().int().nonnegative(),
logFormat: z.enum(["json", "pretty"]),
logLevel: z.enum(["debug", "info", "warn", "error"]),
cfAccounts: z.string().nullable(),
cfZones: z.string().nullable(),
cfFreeTierAccounts: z.string(),
metricsDenylist: z.string(),
excludeHost: z.boolean(),
httpStatusGroup: z.boolean(),
} as const;
/**
* Zod schema for partial configuration overrides (all fields optional).
*/
export const ConfigOverridesSchema = z
.object({
queryLimit: ConfigValueSchemas.queryLimit.optional(),
scrapeDelaySeconds: ConfigValueSchemas.scrapeDelaySeconds.optional(),
timeWindowSeconds: ConfigValueSchemas.timeWindowSeconds.optional(),
metricRefreshIntervalSeconds:
ConfigValueSchemas.metricRefreshIntervalSeconds.optional(),
accountListCacheTtlSeconds:
ConfigValueSchemas.accountListCacheTtlSeconds.optional(),
zoneListCacheTtlSeconds:
ConfigValueSchemas.zoneListCacheTtlSeconds.optional(),
sslCertsCacheTtlSeconds:
ConfigValueSchemas.sslCertsCacheTtlSeconds.optional(),
healthCheckCacheTtlSeconds:
ConfigValueSchemas.healthCheckCacheTtlSeconds.optional(),
logFormat: ConfigValueSchemas.logFormat.optional(),
logLevel: ConfigValueSchemas.logLevel.optional(),
cfAccounts: ConfigValueSchemas.cfAccounts.optional(),
cfZones: ConfigValueSchemas.cfZones.optional(),
cfFreeTierAccounts: ConfigValueSchemas.cfFreeTierAccounts.optional(),
metricsDenylist: ConfigValueSchemas.metricsDenylist.optional(),
excludeHost: ConfigValueSchemas.excludeHost.optional(),
httpStatusGroup: ConfigValueSchemas.httpStatusGroup.optional(),
})
.readonly();
/**
* Partial configuration overrides stored in KV.
*/
export type ConfigOverrides = z.infer<typeof ConfigOverridesSchema>;
/**
* Zod schema for fully resolved configuration (all fields required).
*/
export const ResolvedConfigSchema = z
.object({
queryLimit: ConfigValueSchemas.queryLimit,
scrapeDelaySeconds: ConfigValueSchemas.scrapeDelaySeconds,
timeWindowSeconds: ConfigValueSchemas.timeWindowSeconds,
metricRefreshIntervalSeconds:
ConfigValueSchemas.metricRefreshIntervalSeconds,
accountListCacheTtlSeconds: ConfigValueSchemas.accountListCacheTtlSeconds,
zoneListCacheTtlSeconds: ConfigValueSchemas.zoneListCacheTtlSeconds,
sslCertsCacheTtlSeconds: ConfigValueSchemas.sslCertsCacheTtlSeconds,
healthCheckCacheTtlSeconds: ConfigValueSchemas.healthCheckCacheTtlSeconds,
logFormat: ConfigValueSchemas.logFormat,
logLevel: ConfigValueSchemas.logLevel,
cfAccounts: ConfigValueSchemas.cfAccounts,
cfZones: ConfigValueSchemas.cfZones,
cfFreeTierAccounts: ConfigValueSchemas.cfFreeTierAccounts,
metricsDenylist: ConfigValueSchemas.metricsDenylist,
excludeHost: ConfigValueSchemas.excludeHost,
httpStatusGroup: ConfigValueSchemas.httpStatusGroup,
})
.readonly();
/**
* Fully resolved configuration with all fields populated.
*/
export type ResolvedConfig = z.infer<typeof ResolvedConfigSchema>;
/**
* Optional environment variables not defined in wrangler.jsonc.
*/
type OptionalEnvVars = {
METRICS_DENYLIST?: string;
CF_ACCOUNTS?: string;
CF_ZONES?: string;
CF_FREE_TIER_ACCOUNTS?: string;
HEALTH_CHECK_CACHE_TTL_SECONDS?: string;
};
/**
* Gets default configuration values from environment variables.
*
* @param env Worker environment bindings.
* @returns Resolved configuration with defaults applied.
*/
export function getEnvDefaults(env: Env): ResolvedConfig {
const optionalEnv = env as Env & OptionalEnvVars;
return {
queryLimit: z.coerce.number().catch(10000).parse(env.QUERY_LIMIT),
scrapeDelaySeconds: z.coerce
.number()
.catch(300)
.parse(env.SCRAPE_DELAY_SECONDS),
timeWindowSeconds: z.coerce
.number()
.catch(60)
.parse(env.TIME_WINDOW_SECONDS),
metricRefreshIntervalSeconds: z.coerce
.number()
.catch(60)
.parse(env.METRIC_REFRESH_INTERVAL_SECONDS),
accountListCacheTtlSeconds: z.coerce
.number()
.catch(600)
.parse(env.ACCOUNT_LIST_CACHE_TTL_SECONDS),
zoneListCacheTtlSeconds: z.coerce
.number()
.catch(1800)
.parse(env.ZONE_LIST_CACHE_TTL_SECONDS),
sslCertsCacheTtlSeconds: z.coerce
.number()
.catch(1800)
.parse(env.SSL_CERTS_CACHE_TTL_SECONDS),
healthCheckCacheTtlSeconds: z.coerce
.number()
.catch(10)
.parse(optionalEnv.HEALTH_CHECK_CACHE_TTL_SECONDS),
logFormat: z.enum(["json", "pretty"]).catch("pretty").parse(env.LOG_FORMAT),
logLevel: z
.enum(["debug", "info", "warn", "error"])
.catch("info")
.parse(env.LOG_LEVEL),
cfAccounts: optionalEnv.CF_ACCOUNTS?.trim() || null,
cfZones: optionalEnv.CF_ZONES?.trim() || null,
cfFreeTierAccounts: optionalEnv.CF_FREE_TIER_ACCOUNTS?.trim() ?? "",
metricsDenylist: optionalEnv.METRICS_DENYLIST?.trim() ?? "",
excludeHost: z.coerce.boolean().catch(false).parse(env.EXCLUDE_HOST),
httpStatusGroup: z.coerce
.boolean()
.catch(false)
.parse(env.CF_HTTP_STATUS_GROUP),
};
}
/**
* Reads configuration overrides from KV storage.
* Returns empty object on parse errors or missing data.
*
* @param env Worker environment bindings.
* @returns Configuration overrides or empty object.
*/
async function readOverrides(env: Env): Promise<ConfigOverrides> {
const raw = await env.CONFIG_KV.get(KV_KEY);
if (!raw) return {};
try {
const parsed: unknown = JSON.parse(raw);
const result = ConfigOverridesSchema.safeParse(parsed);
if (!result.success) {
console.error("Invalid config overrides in KV, using defaults", {
error: result.error.message,
});
return {};
}
return result.data;
} catch {
console.error("Failed to parse config overrides from KV, using defaults");
return {};
}
}
/**
* Writes configuration overrides to KV storage.
*
* @param env Worker environment bindings.
* @param overrides Configuration overrides to persist.
*/
async function writeOverrides(
env: Env,
overrides: ConfigOverrides,
): Promise<void> {
await env.CONFIG_KV.put(KV_KEY, JSON.stringify(overrides));
}
/**
* Merges configuration overrides with environment defaults.
*
* @param defaults Default configuration from environment.
* @param overrides Partial overrides from KV storage.
* @returns Fully resolved configuration.
*/
function mergeConfig(
defaults: ResolvedConfig,
overrides: ConfigOverrides,
): ResolvedConfig {
return {
queryLimit: overrides.queryLimit ?? defaults.queryLimit,
scrapeDelaySeconds:
overrides.scrapeDelaySeconds ?? defaults.scrapeDelaySeconds,
timeWindowSeconds:
overrides.timeWindowSeconds ?? defaults.timeWindowSeconds,
metricRefreshIntervalSeconds:
overrides.metricRefreshIntervalSeconds ??
defaults.metricRefreshIntervalSeconds,
accountListCacheTtlSeconds:
overrides.accountListCacheTtlSeconds ??
defaults.accountListCacheTtlSeconds,
zoneListCacheTtlSeconds:
overrides.zoneListCacheTtlSeconds ?? defaults.zoneListCacheTtlSeconds,
sslCertsCacheTtlSeconds:
overrides.sslCertsCacheTtlSeconds ?? defaults.sslCertsCacheTtlSeconds,
healthCheckCacheTtlSeconds:
overrides.healthCheckCacheTtlSeconds ??
defaults.healthCheckCacheTtlSeconds,
logFormat: overrides.logFormat ?? defaults.logFormat,
logLevel: overrides.logLevel ?? defaults.logLevel,
cfAccounts:
overrides.cfAccounts !== undefined
? overrides.cfAccounts
: defaults.cfAccounts,
cfZones:
overrides.cfZones !== undefined ? overrides.cfZones : defaults.cfZones,
cfFreeTierAccounts:
overrides.cfFreeTierAccounts ?? defaults.cfFreeTierAccounts,
metricsDenylist: overrides.metricsDenylist ?? defaults.metricsDenylist,
excludeHost: overrides.excludeHost ?? defaults.excludeHost,
httpStatusGroup: overrides.httpStatusGroup ?? defaults.httpStatusGroup,
};
}
/**
* Gets resolved configuration by merging KV overrides with environment defaults.
*
* @param env Worker environment bindings.
* @returns Fully resolved configuration.
*/
export async function getConfig(env: Env): Promise<ResolvedConfig> {
const defaults = getEnvDefaults(env);
const overrides = await readOverrides(env);
return mergeConfig(defaults, overrides);
}
/**
* Gets a single configuration key value.
*
* @param env Worker environment bindings.
* @param key Configuration key to retrieve.
* @returns Value for the specified configuration key.
*/
export async function getConfigKey<K extends ConfigKey>(
env: Env,
key: K,
): Promise<ResolvedConfig[K]> {
const config = await getConfig(env);
return config[key];
}
/**
* Validates a value for a specific configuration key.
*
* @param key Configuration key to validate against.
* @param value Value to validate.
* @returns Validation result with parsed data or Zod error.
*/
export function validateConfigValue(
key: ConfigKey,
value: unknown,
): { success: true; data: unknown } | { success: false; error: z.ZodError } {
return ConfigValueSchemas[key].safeParse(value);
}
/**
* Result type for setConfigKey operation.
*/
type SetConfigKeyResult =
| { success: true; config: ResolvedConfig }
| { success: false; error: z.ZodError };
/**
* Sets a single configuration key override with validation.
*
* @param env Worker environment bindings.
* @param key Configuration key to set.
* @param value Value to set for the key.
* @returns Result with updated config or validation error.
*/
export async function setConfigKey(
env: Env,
key: ConfigKey,
value: unknown,
): Promise<SetConfigKeyResult> {
const result = ConfigValueSchemas[key].safeParse(value);
if (!result.success) {
return { success: false, error: result.error };
}
const overrides = await readOverrides(env);
const updated = { ...overrides, [key]: result.data };
await writeOverrides(env, updated);
return {
success: true,
config: mergeConfig(getEnvDefaults(env), updated),
};
}
/**
* Resets a single configuration key to its environment default.
*
* @param env Worker environment bindings.
* @param key Configuration key to reset.
* @returns Resolved configuration after reset.
*/
export async function resetConfigKey(
env: Env,
key: ConfigKey,
): Promise<ResolvedConfig> {
const overrides = await readOverrides(env);
const { [key]: _, ...remaining } = overrides;
await writeOverrides(env, remaining);
return mergeConfig(getEnvDefaults(env), remaining);
}
/**
* Resets all configuration overrides to environment defaults.
*
* @param env Worker environment bindings.
* @returns Resolved configuration with only environment defaults.
*/
export async function resetAllConfig(env: Env): Promise<ResolvedConfig> {
await env.CONFIG_KV.delete(KV_KEY);
return getEnvDefaults(env);
}

41
src/lib/time.ts Normal file
View File

@@ -0,0 +1,41 @@
import type { TimeRange } from "./types";
/**
* Computes time range for GraphQL queries with delay and window.
* Rounds to nearest minute and applies delay to account for ingestion lag.
*
* @param scrapeDelaySeconds Delay in seconds to account for ingestion lag.
* @param timeWindowSeconds Window size in seconds for the time range.
* @returns Time range with mintime and maxtime ISO strings.
*/
export function getTimeRange(
scrapeDelaySeconds: number = 300,
timeWindowSeconds: number = 60,
): TimeRange {
const now = new Date();
now.setSeconds(0, 0);
now.setTime(now.getTime() - scrapeDelaySeconds * 1000);
const maxtime = now.toISOString();
now.setTime(now.getTime() - timeWindowSeconds * 1000);
const mintime = now.toISOString();
return { mintime, maxtime };
}
/**
* Generates deterministic metric key from name and labels.
* Labels are sorted alphabetically for consistency.
*
* @param name Metric name.
* @param labels Label key value pairs.
* @returns Formatted metric key string.
*/
export function metricKey(
name: string,
labels: Record<string, string>,
): string {
const sortedLabels = Object.entries(labels)
.sort(([a], [b]) => a.localeCompare(b))
.map(([k, v]) => `${k}=${v}`)
.join(",");
return `${name}{${sortedLabels}}`;
}

195
src/lib/types.ts Normal file
View File

@@ -0,0 +1,195 @@
import z from "zod";
import { MetricDefinitionSchema } from "./metrics";
// Re-export metric types from metrics.ts
export type { MetricDefinition, MetricType, MetricValue } from "./metrics";
export { MetricDefinitionSchema } from "./metrics";
/**
* Zod schema for MetricExporter scope: account-level or zone-level.
*/
export const ScopeTypeSchema = z.enum(["account", "zone"]);
/**
* Scope discriminator for MetricExporter DOs.
*/
export type ScopeType = z.infer<typeof ScopeTypeSchema>;
/**
* String literal type for MetricExporter DO IDs: "scope:id:queryName".
*/
export type MetricExporterIdString =
`${"account" | "zone"}:${string}:${string}`;
/**
* Zod schema that parses and validates MetricExporter DO ID strings.
* Transforms "scope:id:query" into structured object.
*/
export const MetricExporterIdSchema = z
.string()
.regex(/^(account|zone):[^:]+:[^:]+$/)
.transform((s) => {
const parts = s.split(":");
// Regex guarantees exactly 3 parts with account|zone prefix
const scopeType = ScopeTypeSchema.parse(parts[0]);
const scopeId = z.string().min(1).parse(parts[1]);
const queryName = z.string().min(1).parse(parts[2]);
return { scopeType, scopeId, queryName };
});
/**
* Parsed MetricExporter DO identifier with scope, ID, and query name.
*/
export type MetricExporterId = z.infer<typeof MetricExporterIdSchema>;
/**
* Zod schema for counter state tracking previous value and accumulated total.
*/
export const CounterStateSchema = z
.object({
prev: z.number(),
accumulated: z.number(),
})
.readonly();
/**
* Counter state for Prometheus monotonic counter semantics.
*/
export type CounterState = z.infer<typeof CounterStateSchema>;
/**
* Zod schema for persistent metric state in MetricExporter DO storage.
*/
export const MetricStateSchema = z
.object({
accountId: z.string().optional(),
accountName: z.string().optional(),
counters: z.record(z.string(), CounterStateSchema),
metrics: z.array(MetricDefinitionSchema).readonly(),
lastFetch: z.number(),
lastError: z.string().optional(),
})
.readonly();
/**
* Persistent metric state stored in MetricExporter DO.
*/
export type MetricState = z.infer<typeof MetricStateSchema>;
/**
* Zod schema for Cloudflare account API response.
*/
export const AccountSchema = z
.object({
id: z.string(),
name: z.string(),
})
.readonly();
/**
* Cloudflare account with ID and name.
*/
export type Account = z.infer<typeof AccountSchema>;
/**
* Zod schema for Cloudflare zone API response with plan and account.
*/
export const ZoneSchema = z
.object({
id: z.string(),
name: z.string(),
status: z.string(),
plan: z.object({
id: z.string(),
name: z.string(),
}),
account: z.object({
id: z.string(),
name: z.string(),
}),
})
.readonly();
/**
* Cloudflare zone with plan and account associations.
*/
export type Zone = z.infer<typeof ZoneSchema>;
/**
* Zod schema for Cloudflare SSL certificate API response.
*/
export const SSLCertificateSchema = z
.object({
id: z.string(),
type: z.string(),
status: z.string(),
issuer: z.string(),
expiresOn: z.string(),
hosts: z.array(z.string()),
})
.readonly();
/**
* SSL certificate with expiration and host coverage.
*/
export type SSLCertificate = z.infer<typeof SSLCertificateSchema>;
/**
* Zod schema for GraphQL query time range with ISO 8601 timestamps.
*/
export const TimeRangeSchema = z
.object({
mintime: z.string(),
maxtime: z.string(),
})
.readonly();
/**
* Time range for GraphQL queries with start and end timestamps.
*/
export type TimeRange = z.infer<typeof TimeRangeSchema>;
/**
* Zod schema for load balancer origin configuration.
*/
export const LoadBalancerOriginSchema = z
.object({
name: z.string(),
address: z.string(),
enabled: z.boolean(),
weight: z.number(),
})
.passthrough()
.readonly();
/**
* Load balancer origin with weight configuration.
*/
export type LoadBalancerOrigin = z.infer<typeof LoadBalancerOriginSchema>;
/**
* Zod schema for load balancer pool configuration.
*/
export const LoadBalancerPoolSchema = z
.object({
id: z.string(),
name: z.string(),
enabled: z.boolean(),
origins: z.array(LoadBalancerOriginSchema),
})
.passthrough()
.readonly();
/**
* Load balancer pool with origins.
*/
export type LoadBalancerPool = z.infer<typeof LoadBalancerPoolSchema>;
/**
* Combined load balancer with resolved pools.
*/
export type LoadBalancerWithPools = {
readonly id: string;
readonly name: string;
readonly pools: readonly LoadBalancerPool[];
};

128
src/worker.tsx Normal file
View File

@@ -0,0 +1,128 @@
import { env } from "cloudflare:workers";
import { Hono } from "hono";
import { LandingPage } from "./components/LandingPage";
import { AccountMetricCoordinator } from "./durable-objects/AccountMetricCoordinator";
import { MetricCoordinator } from "./durable-objects/MetricCoordinator";
import { MetricExporter } from "./durable-objects/MetricExporter";
import { type AppConfig, parseConfig } from "./lib/config";
import { checkHealth, healthResponse } from "./lib/health";
import { configFromEnv, createLogger } from "./lib/logger";
import {
ConfigKeySchema,
getConfig,
getConfigKey,
getEnvDefaults,
resetAllConfig,
resetConfigKey,
setConfigKey,
} from "./lib/runtime-config";
export { MetricCoordinator, AccountMetricCoordinator, MetricExporter };
type Variables = { config: AppConfig };
const app = new Hono<{ Bindings: Env; Variables: Variables }>();
// Parse config middleware
app.use("*", async (c, next) => {
c.set("config", parseConfig(c.env));
await next();
});
// Disable guards
app.use("*", async (c, next) => {
const path = c.req.path;
if (c.var.config.disableUi && path === "/") {
return c.text("Not Found", 404);
}
if (c.var.config.disableConfigApi && path.startsWith("/config")) {
return c.text("Not Found", 404);
}
await next();
});
// Dynamic metrics path middleware (runs before routing)
app.get(env.METRICS_PATH, async (c) => {
const logger = createLogger("worker", configFromEnv(c.env)).withContext({
request_id: crypto.randomUUID(),
});
logger.info("Metrics request received");
try {
const coordinator = await MetricCoordinator.get(c.env);
const output = await coordinator.export();
logger.info("Metrics exported successfully");
return c.text(output, 200, {
"Content-Type": "text/plain; charset=utf-8",
});
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
logger.error("Failed to collect metrics", { error: message });
return c.text(`Error collecting metrics: ${message}`, 500);
}
});
// Routes
app.get("/", (c) => c.html(<LandingPage config={c.var.config} />));
app.get("/health", async (c) => {
const health = await checkHealth(c.env);
return healthResponse(health);
});
// Config API routes
app.get("/config", async (c) => {
const config = await getConfig(c.env);
return c.json(config);
});
app.get("/config/defaults", (c) => {
const defaults = getEnvDefaults(c.env);
return c.json(defaults);
});
app.get("/config/:key", async (c) => {
const keyResult = ConfigKeySchema.safeParse(c.req.param("key"));
if (!keyResult.success) {
return c.json({ error: "Invalid config key" }, 400);
}
const value = await getConfigKey(c.env, keyResult.data);
return c.json({ key: keyResult.data, value });
});
app.put("/config/:key", async (c) => {
const keyResult = ConfigKeySchema.safeParse(c.req.param("key"));
if (!keyResult.success) {
return c.json({ error: "Invalid config key" }, 400);
}
const body = await c.req.json<{ value: unknown }>().catch(() => null);
if (!body || !("value" in body)) {
return c.json({ error: "Request body must contain 'value'" }, 400);
}
const result = await setConfigKey(c.env, keyResult.data, body.value);
if (!result.success) {
return c.json(
{ error: "Invalid value", details: result.error.issues },
400,
);
}
return c.json(result.config);
});
app.delete("/config/:key", async (c) => {
const keyResult = ConfigKeySchema.safeParse(c.req.param("key"));
if (!keyResult.success) {
return c.json({ error: "Invalid config key" }, 400);
}
const config = await resetConfigKey(c.env, keyResult.data);
return c.json(config);
});
app.delete("/config", async (c) => {
const config = await resetAllConfig(c.env);
return c.json(config);
});
app.notFound((c) => c.text("Not Found", 404));
export default app;

View File

@@ -1,43 +1,46 @@
{
"compilerOptions": {
/* Visit https://aka.ms/tsconfig.json to read more about this file */
/* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */
"target": "es2021",
/* Specify a set of bundled library declaration files that describe the target runtime environment. */
"lib": ["es2021"],
/* Specify what JSX code is generated. */
// Environment setup & latest features
"lib": ["ESNext"],
"target": "ESNext",
"module": "Preserve",
"moduleDetection": "force",
"jsx": "react-jsx",
/* Specify what module code is generated. */
"module": "es2022",
/* Specify how TypeScript looks up a file from a given module specifier. */
"moduleResolution": "node",
"jsxImportSource": "hono/jsx",
"allowJs": true,
// Bundler mode
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"noEmit": true,
// Best practices
"strict": true,
"skipLibCheck": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
// Some stricter flags (disabled by default)
"noUnusedLocals": false,
"noUnusedParameters": false,
"noPropertyAccessFromIndexSignature": false,
/* Enable importing .json files */
"resolveJsonModule": true,
/* Allow JavaScript files to be a part of your program. Use the `checkJS` option to get errors from these files. */
"allowJs": true,
/* Enable error reporting in type-checked JavaScript files. */
"checkJs": false,
/* Disable emitting files from a compilation. */
"noEmit": true,
/* Ensure that each file can be safely transpiled without relying on other imports. */
"isolatedModules": true,
/* Allow 'import x from y' when a module doesn't have a default export. */
"allowSyntheticDefaultImports": true,
/* Ensure that casing is correct in imports. */
"forceConsistentCasingInFileNames": true,
/* Enable all strict type-checking options. */
"strict": true,
/* Skip type checking all .d.ts files. */
"skipLibCheck": true,
"types": [
"./worker-configuration.d.ts"
"types": ["./worker-configuration.d.ts"],
/* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */
"plugins": [
{
"name": "gql.tada/ts-plugin",
"schema": "./src/gql/schema.gql",
"tadaOutputLocation": "./src/gql/graphql-env.d.ts"
}
]
}
}

View File

@@ -1,16 +1,41 @@
/* eslint-disable */
// Generated by Wrangler by running `wrangler types` (hash: 543f992083f7639592591242de0b7088)
// Runtime types generated with workerd@1.20251125.0 2025-12-02
// Generated by Wrangler by running `wrangler types` (hash: d16ad07d8f190b43581686102026ae12)
// Runtime types generated with workerd@1.20251202.0 2025-12-09 nodejs_compat
declare namespace Cloudflare {
interface GlobalProps {
mainModule: typeof import("./src/index");
durableNamespaces: "MyDurableObject";
mainModule: typeof import("./src/worker");
durableNamespaces: "MetricExporter" | "MetricCoordinator" | "AccountMetricCoordinator";
}
interface Env {
MY_DURABLE_OBJECT: DurableObjectNamespace<import("./src/index").MyDurableObject>;
CONFIG_KV: KVNamespace;
QUERY_LIMIT: 10000;
SCRAPE_DELAY_SECONDS: 300;
TIME_WINDOW_SECONDS: 60;
METRIC_REFRESH_INTERVAL_SECONDS: 60;
LOG_FORMAT: "json";
LOG_LEVEL: "info";
ACCOUNT_LIST_CACHE_TTL_SECONDS: 600;
ZONE_LIST_CACHE_TTL_SECONDS: 1800;
SSL_CERTS_CACHE_TTL_SECONDS: 1800;
EXCLUDE_HOST: false;
CF_HTTP_STATUS_GROUP: false;
METRICS_PATH: "/metrics";
DISABLE_UI: false;
DISABLE_CONFIG_API: false;
CLOUDFLARE_API_TOKEN: string;
MetricCoordinator: DurableObjectNamespace<import("./src/worker").MetricCoordinator>;
AccountMetricCoordinator: DurableObjectNamespace<import("./src/worker").AccountMetricCoordinator>;
MetricExporter: DurableObjectNamespace<import("./src/worker").MetricExporter>;
CF_API_RATE_LIMITER: RateLimit;
}
}
interface Env extends Cloudflare.Env {}
type StringifyValues<EnvType extends Record<string, unknown>> = {
[Binding in keyof EnvType]: EnvType[Binding] extends string ? EnvType[Binding] : string;
};
declare namespace NodeJS {
interface ProcessEnv extends StringifyValues<Pick<Cloudflare.Env, "QUERY_LIMIT" | "SCRAPE_DELAY_SECONDS" | "TIME_WINDOW_SECONDS" | "METRIC_REFRESH_INTERVAL_SECONDS" | "LOG_FORMAT" | "LOG_LEVEL" | "ACCOUNT_LIST_CACHE_TTL_SECONDS" | "ZONE_LIST_CACHE_TTL_SECONDS" | "SSL_CERTS_CACHE_TTL_SECONDS" | "EXCLUDE_HOST" | "CF_HTTP_STATUS_GROUP" | "METRICS_PATH" | "DISABLE_UI" | "DISABLE_CONFIG_API" | "CLOUDFLARE_API_TOKEN">> {}
}
// Begin runtime types
/*! *****************************************************************************
@@ -10808,8 +10833,11 @@ type InstanceStatus = {
| 'complete' | 'waiting' // instance is hibernating and waiting for sleep or event to finish
| 'waitingForPause' // instance is finishing the current work to pause
| 'unknown';
error?: string;
output?: object;
error?: {
name: string;
message: string;
};
output?: unknown;
};
interface WorkflowError {
code?: number;

View File

@@ -4,56 +4,68 @@
*/
{
"$schema": "node_modules/wrangler/config-schema.json",
"name": "cloudflare-prometheus-exporter-v2",
"main": "src/index.ts",
"compatibility_date": "2025-12-02",
"name": "cloudflare-prometheus-exporter",
"main": "src/worker.tsx",
"compatibility_date": "2025-12-09",
"compatibility_flags": ["nodejs_compat"],
"migrations": [
{
"tag": "v1",
"new_sqlite_classes": [
"MyDurableObject"
],
"tag": "v1"
"MetricExporter",
"MetricCoordinator",
"AccountMetricCoordinator"
]
}
],
"durable_objects": {
"bindings": [
{
"class_name": "MyDurableObject",
"name": "MY_DURABLE_OBJECT"
"name": "MetricCoordinator",
"class_name": "MetricCoordinator"
},
{
"name": "AccountMetricCoordinator",
"class_name": "AccountMetricCoordinator"
},
{
"name": "MetricExporter",
"class_name": "MetricExporter"
}
]
},
"observability": {
"enabled": true
},
"kv_namespaces": [
{
"binding": "CONFIG_KV"
}
],
"ratelimits": [
{
"name": "CF_API_RATE_LIMITER",
"namespace_id": "1",
"simple": {
"limit": 40,
"period": 10
}
}
],
"vars": {
"QUERY_LIMIT": 10000,
"SCRAPE_DELAY_SECONDS": 300,
"TIME_WINDOW_SECONDS": 60,
"METRIC_REFRESH_INTERVAL_SECONDS": 60,
"LOG_FORMAT": "json",
"LOG_LEVEL": "info",
"ACCOUNT_LIST_CACHE_TTL_SECONDS": 600,
"ZONE_LIST_CACHE_TTL_SECONDS": 1800,
"SSL_CERTS_CACHE_TTL_SECONDS": 1800,
"EXCLUDE_HOST": false,
"CF_HTTP_STATUS_GROUP": false,
"METRICS_PATH": "/metrics",
"DISABLE_UI": false,
"DISABLE_CONFIG_API": false
}
/**
* Smart Placement
* Docs: https://developers.cloudflare.com/workers/configuration/smart-placement/#smart-placement
*/
// "placement": { "mode": "smart" }
/**
* Bindings
* Bindings allow your Worker to interact with resources on the Cloudflare Developer Platform, including
* databases, object storage, AI inference, real-time communication and more.
* https://developers.cloudflare.com/workers/runtime-apis/bindings/
*/
/**
* Environment Variables
* https://developers.cloudflare.com/workers/wrangler/configuration/#environment-variables
*/
// "vars": { "MY_VARIABLE": "production_value" }
/**
* Note: Use secrets to store sensitive data.
* https://developers.cloudflare.com/workers/configuration/secrets/
*/
/**
* Static Assets
* https://developers.cloudflare.com/workers/static-assets/binding/
*/
// "assets": { "directory": "./public/", "binding": "ASSETS" }
/**
* Service Bindings (communicate between multiple Workers)
* https://developers.cloudflare.com/workers/wrangler/configuration/#service-bindings
*/
// "services": [{ "binding": "MY_SERVICE", "service": "my-service" }]
}
}