Skip to content

Commit 6acd059

Browse files
authored
TRAC-469: add support for wae metrics (#15)
1 parent c2c1be6 commit 6acd059

4 files changed

Lines changed: 205 additions & 1 deletion

File tree

README.md

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ dashboard.
2929
Required authentication scopes:
3030

3131
- `Zone/Analytics:Read` is required for zone-level metrics
32-
- `Account/Account Analytics:Read` is required for Worker metrics
32+
- `Account/Account Analytics:Read` is required for Worker metrics and Workers Analytics Engine (WAE) queries
3333
- `Account/Account Settings:Read` is required for Worker metrics (for listing accessible accounts, scraping all available
3434
Workers included in authentication scope)
3535
- `Zone/Firewall Services:Read` is required to fetch zone rule name for `cloudflare_zone_firewall_events_count` metric
@@ -67,6 +67,7 @@ The exporter can be configured using env variables or command flags.
6767
| `SCRAPE_INTERVAL` | scrape interval in seconds (will query cloudflare every SCRAPE_INTERVAL seconds), default `60` |
6868
| `METRICS_DENYLIST` | (Optional) cloudflare-exporter metrics to not export, comma delimited list of cloudflare-exporter metrics. If not set, all metrics are exported |
6969
| `KV_NAMESPACE_IDS` | (Optional) KV namespace IDs to track individually, comma delimited. Unlisted namespaces are aggregated as `other` |
70+
| `WAE_DATASET_PREFIX` | (Optional) Prefix used to discover Workers Analytics Engine (WAE) datasets via `SHOW TABLES`. Datasets matching this prefix are auto-discovered and queried; the suffix (with `_``-`) becomes the `script_name` label. Empty (default) disables WAE discovery. See [WAE integration](#workers-analytics-engine-wae-integration) below. |
7071
| `ENABLE_PPROF` | (Optional) enable pprof profiling endpoints at `/debug/pprof/`. Accepts `true` or `false`, default `false`. **Warning**: Only enable in development/debugging environments |
7172
| `ZONE_<NAME>` | `DEPRECATED since 0.0.5` (optional) Zone ID. Add zones you want to scrape by adding env vars in this format. You can find the zone ids in Cloudflare dashboards. |
7273
| `LOG_LEVEL` | Set loglevel. Options are error, warn, info, debug. default `error` |
@@ -86,6 +87,7 @@ Corresponding flags:
8687
-scrape_delay=300: scrape delay in seconds, defaults to 300
8788
-scrape_interval=60: scrape interval in seconds, defaults to 60
8889
-kv_namespace_ids="": KV namespace IDs to track individually, comma delimited
90+
-wae_dataset_prefix="": prefix for auto-discovered WAE datasets (empty disables discovery)
8991
-metrics_denylist="": cloudflare-exporter metrics to not export, comma delimited list
9092
-enable_pprof=false: enable pprof profiling endpoints at /debug/pprof/
9193
-log_level="error": log level(error,warn,info,debug)
@@ -140,8 +142,37 @@ Note: `ZONE_<name>` configuration is not supported as flag.
140142
# HELP cloudflare_queue_operations_bytes Total bytes processed by queue message operations
141143
# HELP cloudflare_queue_operations_lag_time Average lag time between write and read/delete (milliseconds)
142144
# HELP cloudflare_queue_operations_retry_count Average retry count for queue message operations
145+
# HELP cloudflare_worker_operation_duration_seconds Average duration of a worker operation in seconds, sliced by metric_type (worker-defined event type) and label (worker-defined slice)
143146
```
144147

148+
## Workers Analytics Engine (WAE) integration
149+
150+
The exporter can scrape Cloudflare Workers Analytics Engine datasets and expose them as Prometheus metrics. WAE datasets are auto-discovered by name prefix — no per-worker configuration in the exporter is required.
151+
152+
Discovery is **disabled by default**. Set `WAE_DATASET_PREFIX` (e.g. `my_worker_metrics_`) to enable.
153+
154+
### Onboarding a new worker
155+
156+
1. In your worker's wrangler config, define a WAE binding whose dataset name follows the convention `<WAE_DATASET_PREFIX><script_name_underscored>`. For example, with a configured prefix of `my_worker_metrics_`, a worker called `my-service-prod` names its dataset `my_worker_metrics_my_service_prod`. WAE dataset names must be valid SQL identifiers, so dashes are forbidden; the exporter converts the suffix back to dashes when emitting the `script_name` label.
157+
158+
2. Emit data points with the following schema:
159+
160+
| Field | Meaning |
161+
|---|---|
162+
| `index1` | metric_type string — categorizes the operation (e.g. `origin_fetch`, `api_call`) |
163+
| `blob1` | label — secondary slicing dimension (e.g. status, endpoint) |
164+
| `double1` | duration in milliseconds |
165+
166+
Additional `blobN` / `doubleN` fields are ignored by the exporter; they're for your own diagnostic queries against WAE.
167+
168+
3. Deploy. On the next scrape cycle the exporter will discover the dataset and expose `cloudflare_worker_operation_duration_seconds` series labeled with your `script_name`, `metric_type` (from `index1`), and `label` (from `blob1`).
169+
170+
### Notes
171+
172+
- The WAE aggregation window is 5 minutes. If your worker is low-traffic, expect series to disappear when traffic doesn't land in the window.
173+
- Series for a `script_name` whose dataset no longer exists (or has had no traffic in the window) are automatically removed from `/metrics` — they don't go stale at their last value.
174+
- Discovery uses `SHOW TABLES` against the WAE SQL API and requires `Account/Account Analytics:Read`.
175+
145176
## Helm chart repository
146177

147178
To deploy the exporter into Kubernetes, we recommend using our manager Helm repository:

main.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ var (
2424
cfclient *cf.Client
2525
cftimeout time.Duration
2626
gql *GraphQL
27+
waeClient *WAEClient
2728
log = logrus.New()
2829

2930
// kvTrackedNamespaces is the set of KV namespace IDs that get their own
@@ -122,6 +123,7 @@ func fetchMetrics(deniedMetricsSet MetricsSet) {
122123
go fetchLoadblancerPoolsHealth(a, &wg)
123124
go fetchZeroTrustAnalyticsForAccount(a, &wg)
124125
go fetchDNSFirewallAnalytics(a, &wg, deniedMetricsSet)
126+
go fetchWorkerWAEAnalytics(a, &wg, deniedMetricsSet)
125127
}
126128

127129
zones := fetchZones(accounts)
@@ -283,6 +285,10 @@ func main() {
283285
viper.BindEnv("metrics_denylist")
284286
viper.SetDefault("metrics_denylist", "")
285287

288+
flags.String("wae_dataset_prefix", "", "Prefix used to discover Cloudflare Workers Analytics Engine datasets via SHOW TABLES. The remainder of each dataset name (with underscores converted to dashes) is emitted as the script_name label. Empty (default) disables WAE discovery.")
289+
viper.BindEnv("wae_dataset_prefix")
290+
viper.SetDefault("wae_dataset_prefix", "")
291+
286292
flags.String("log_level", "info", "log level")
287293
viper.BindEnv("log_level")
288294
viper.SetDefault("log_level", "info")
@@ -330,6 +336,7 @@ func main() {
330336
Transport: middlewares,
331337
}
332338
gql = NewGraphQLClient(gqlHTTPClient)
339+
waeClient = NewWAEClient(gqlHTTPClient)
333340
} else if len(viper.GetString("cf_api_email")) > 0 && len(viper.GetString("cf_api_key")) > 0 {
334341
cfclient = cf.NewClient(
335342
cfoption.WithAPIKey(viper.GetString("cf_api_key")),
@@ -343,6 +350,7 @@ func main() {
343350
Transport: middlewares,
344351
}
345352
gql = NewGraphQLClient(gqlHTTPClient)
353+
waeClient = NewWAEClient(gqlHTTPClient)
346354
} else {
347355
log.Fatal("Please provide CF_API_KEY+CF_API_EMAIL or CF_API_TOKEN")
348356
}

prometheus.go

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package main
22

33
import (
4+
"context"
45
"fmt"
56
"strconv"
67
"strings"
@@ -75,6 +76,7 @@ const (
7576
queueOperationBytesMetricName MetricName = "cloudflare_queue_operations_bytes"
7677
queueOperationLagTimeMetricName MetricName = "cloudflare_queue_operations_lag_time"
7778
queueOperationRetryCountMetricName MetricName = "cloudflare_queue_operations_retry_count"
79+
workerOperationDurationMetricName MetricName = "cloudflare_worker_operation_duration_seconds"
7880
)
7981

8082
type MetricsSet map[MetricName]struct{}
@@ -407,6 +409,11 @@ var (
407409
Help: "DNS Firewall query count by query type and response code",
408410
}, []string{"account_id", "account_name", "dns_firewall_id", "query_type", "response_code"},
409411
)
412+
413+
workerOperationDuration = prometheus.NewGaugeVec(prometheus.GaugeOpts{
414+
Name: workerOperationDurationMetricName.String(),
415+
Help: "Average duration of a worker operation in seconds, sliced by metric_type (worker-defined event type) and label (worker-defined slice)",
416+
}, []string{"script_name", "account", "metric_type", "label"})
410417
)
411418

412419
func buildAllMetricsSet() MetricsSet {
@@ -465,6 +472,7 @@ func buildAllMetricsSet() MetricsSet {
465472
allMetricsSet.Add(queueOperationBytesMetricName)
466473
allMetricsSet.Add(queueOperationLagTimeMetricName)
467474
allMetricsSet.Add(queueOperationRetryCountMetricName)
475+
allMetricsSet.Add(workerOperationDurationMetricName)
468476
return allMetricsSet
469477
}
470478

@@ -647,6 +655,9 @@ func mustRegisterMetrics(deniedMetrics MetricsSet) {
647655
if !deniedMetrics.Has(queueOperationRetryCountMetricName) {
648656
prometheus.MustRegister(queueOperationRetryCount)
649657
}
658+
if !deniedMetrics.Has(workerOperationDurationMetricName) {
659+
prometheus.MustRegister(workerOperationDuration)
660+
}
650661
}
651662

652663
func fetchLoadblancerPoolsHealth(account cfaccounts.Account, wg *sync.WaitGroup) {
@@ -1348,6 +1359,89 @@ func addCloudflareTunnelStatus(account cfaccounts.Account) {
13481359
}
13491360
}
13501361

1362+
// fetchWorkerWAEAnalytics discovers all WAE datasets in the account that match
1363+
// the configured prefix and queries each one for worker metrics. The dataset
1364+
// name's suffix (with underscores converted to dashes) is emitted as the
1365+
// script_name label so the series are joinable with cloudflare_worker_* metrics.
1366+
func fetchWorkerWAEAnalytics(account cfaccounts.Account, wg *sync.WaitGroup, deniedMetricsSet MetricsSet) {
1367+
wg.Add(1)
1368+
defer wg.Done()
1369+
1370+
prefix := viper.GetString("wae_dataset_prefix")
1371+
if prefix == "" {
1372+
return
1373+
}
1374+
1375+
ctx, cancel := context.WithTimeout(context.Background(), cftimeout)
1376+
defer cancel()
1377+
1378+
listResp, err := waeClient.Query(ctx, account.ID, "SHOW TABLES")
1379+
if err != nil {
1380+
log.Error("failed to list WAE datasets for account ", account.ID, ": ", err)
1381+
return
1382+
}
1383+
1384+
var datasets []string
1385+
for _, row := range listResp.Data {
1386+
name, _ := row["dataset"].(string)
1387+
if strings.HasPrefix(name, prefix) {
1388+
datasets = append(datasets, name)
1389+
}
1390+
}
1391+
if len(datasets) == 0 {
1392+
log.Debugf("no WAE datasets matched prefix %q in account %s", prefix, account.ID)
1393+
return
1394+
}
1395+
1396+
accountName := strings.ToLower(strings.ReplaceAll(account.Name, " ", "-"))
1397+
1398+
// Drop this account's previous label combinations so labels that no longer
1399+
// see traffic (or scripts that no longer exist) disappear instead of going
1400+
// stale at their last value.
1401+
workerOperationDuration.DeletePartialMatch(prometheus.Labels{"account": accountName})
1402+
1403+
for _, dataset := range datasets {
1404+
scriptName := strings.ReplaceAll(strings.TrimPrefix(dataset, prefix), "_", "-")
1405+
1406+
query := fmt.Sprintf(`
1407+
SELECT
1408+
index1 AS metric_type,
1409+
blob1 AS label,
1410+
SUM(_sample_interval * double1) / SUM(_sample_interval) AS avg_duration_ms,
1411+
SUM(_sample_interval) AS total_requests
1412+
FROM %s
1413+
WHERE timestamp > NOW() - INTERVAL '5' MINUTE
1414+
GROUP BY index1, blob1
1415+
FORMAT JSON`, dataset)
1416+
1417+
resp, err := waeClient.Query(ctx, account.ID, query)
1418+
if err != nil {
1419+
log.Errorf("failed to fetch WAE analytics from %s (account %s): %v", dataset, account.ID, err)
1420+
continue
1421+
}
1422+
1423+
if deniedMetricsSet.Has(workerOperationDurationMetricName) {
1424+
continue
1425+
}
1426+
1427+
for _, row := range resp.Data {
1428+
metricType, _ := row["metric_type"].(string)
1429+
label, _ := row["label"].(string)
1430+
avgDurationMs, _ := row["avg_duration_ms"].(float64)
1431+
if metricType == "" {
1432+
continue
1433+
}
1434+
1435+
workerOperationDuration.With(prometheus.Labels{
1436+
"script_name": scriptName,
1437+
"account": accountName,
1438+
"metric_type": metricType,
1439+
"label": label,
1440+
}).Set(avgDurationMs / 1000.0)
1441+
}
1442+
}
1443+
}
1444+
13511445
// The status of the tunnel.
13521446
// Valid values are:
13531447
// - inactive (tunnel has never been run)

wae.go

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
package main
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"fmt"
7+
"io"
8+
"net/http"
9+
"strings"
10+
)
11+
12+
const waeAPIBase = "https://api.cloudflare.com/client/v4/accounts"
13+
14+
// WAEClient queries the Workers Analytics Engine SQL API.
15+
type WAEClient struct {
16+
httpClient *http.Client
17+
}
18+
19+
// WAEResponse is the JSON response from the WAE SQL API.
20+
// This is NOT the standard Cloudflare v4 API envelope.
21+
type WAEResponse struct {
22+
Meta []WAEColumnMeta `json:"meta"`
23+
Data []map[string]interface{} `json:"data"`
24+
Rows int `json:"rows"`
25+
}
26+
27+
type WAEColumnMeta struct {
28+
Name string `json:"name"`
29+
Type string `json:"type"`
30+
}
31+
32+
func NewWAEClient(httpClient *http.Client) *WAEClient {
33+
if httpClient == nil {
34+
httpClient = http.DefaultClient
35+
}
36+
return &WAEClient{httpClient: httpClient}
37+
}
38+
39+
// Query executes a SQL query against the WAE API for the given account.
40+
func (w *WAEClient) Query(ctx context.Context, accountID, query string) (*WAEResponse, error) {
41+
url := fmt.Sprintf("%s/%s/analytics_engine/sql", waeAPIBase, accountID)
42+
43+
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, strings.NewReader(query))
44+
if err != nil {
45+
return nil, fmt.Errorf("creating WAE request: %w", err)
46+
}
47+
48+
log.Debugf("WAE query (account=%s): %s", accountID, query)
49+
50+
resp, err := w.httpClient.Do(req)
51+
if err != nil {
52+
return nil, fmt.Errorf("executing WAE query: %w", err)
53+
}
54+
defer resp.Body.Close()
55+
56+
body, err := io.ReadAll(resp.Body)
57+
if err != nil {
58+
return nil, fmt.Errorf("reading WAE response: %w", err)
59+
}
60+
61+
if resp.StatusCode != http.StatusOK {
62+
return nil, fmt.Errorf("WAE query failed (HTTP %d): %s", resp.StatusCode, body)
63+
}
64+
65+
var result WAEResponse
66+
if err := json.Unmarshal(body, &result); err != nil {
67+
return nil, fmt.Errorf("parsing WAE response: %w", err)
68+
}
69+
70+
return &result, nil
71+
}

0 commit comments

Comments
 (0)