mirror of
https://github.com/MrUnknownDE/cloudflare-prometheus-exporter.git
synced 2026-04-30 03:13:44 +02:00
Cloudflare Prometheus Exporter
This commit is contained in:
@@ -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
7
.gitignore
vendored
@@ -165,3 +165,10 @@ dist
|
||||
.env*
|
||||
!.env.example
|
||||
.wrangler/
|
||||
|
||||
# claude
|
||||
.claude/
|
||||
|
||||
# playwright mcp
|
||||
.playwright-mcp/
|
||||
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"printWidth": 140,
|
||||
"singleQuote": true,
|
||||
"semi": true,
|
||||
"useTabs": true
|
||||
}
|
||||
5
.vscode/settings.json
vendored
5
.vscode/settings.json
vendored
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"files.associations": {
|
||||
"wrangler.json": "jsonc"
|
||||
}
|
||||
}
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal 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
536
README.md
Normal file
@@ -0,0 +1,536 @@
|
||||
# Cloudflare Prometheus Exporter
|
||||
|
||||
[](https://github.com/cloudflare/cloudflare-prometheus-exporter)
|
||||
|
||||
Export Cloudflare metrics to Prometheus. Built on Cloudflare Workers with Durable Objects for stateful metric accumulation.
|
||||
|
||||
[](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
40
biome.json
Normal 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
332
bun.lock
Normal 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
9
docker-compose.yml
Normal 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
1534
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
26
package.json
26
package.json
@@ -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
10
prometheus.yml
Normal 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
2460
src/cloudflare/client.ts
Normal file
File diff suppressed because it is too large
Load Diff
21
src/cloudflare/gql/client.ts
Normal file
21
src/cloudflare/gql/client.ts
Normal 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
1460
src/cloudflare/gql/graphql-env.d.ts
vendored
Normal file
File diff suppressed because one or more lines are too long
639
src/cloudflare/gql/queries.ts
Normal file
639
src/cloudflare/gql/queries.ts
Normal 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
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
104
src/cloudflare/queries.ts
Normal 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];
|
||||
1191
src/components/LandingPage.tsx
Normal file
1191
src/components/LandingPage.tsx
Normal file
File diff suppressed because it is too large
Load Diff
349
src/components/LandingPageScript.tsx
Normal file
349
src/components/LandingPageScript.tsx
Normal 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>
|
||||
`;
|
||||
};
|
||||
400
src/durable-objects/AccountMetricCoordinator.ts
Normal file
400
src/durable-objects/AccountMetricCoordinator.ts
Normal 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,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
291
src/durable-objects/MetricCoordinator.ts
Normal file
291
src/durable-objects/MetricCoordinator.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
495
src/durable-objects/MetricExporter.ts
Normal file
495
src/durable-objects/MetricExporter.ts
Normal 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
5
src/env.d.ts
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
declare namespace Cloudflare {
|
||||
interface Env {
|
||||
CLOUDFLARE_API_TOKEN: string;
|
||||
}
|
||||
}
|
||||
64
src/index.ts
64
src/index.ts
@@ -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
93
src/lib/config.ts
Normal 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
459
src/lib/errors.ts
Normal 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
58
src/lib/filters.ts
Normal 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
185
src/lib/health.ts
Normal 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
323
src/lib/logger.ts
Normal 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
42
src/lib/metrics.ts
Normal 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
149
src/lib/prometheus.ts
Normal 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
375
src/lib/runtime-config.ts
Normal 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
41
src/lib/time.ts
Normal 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
195
src/lib/types.ts
Normal 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
128
src/worker.tsx
Normal 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;
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
42
worker-configuration.d.ts
vendored
42
worker-configuration.d.ts
vendored
@@ -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;
|
||||
|
||||
@@ -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" }]
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user