Skip to content

Commit b118db8

Browse files
committed
TRAC-469: add support for wae metrics
1 parent c2c1be6 commit b118db8

3 files changed

Lines changed: 173 additions & 0 deletions

File tree

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", "bc_worker_metrics_", "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.")
289+
viper.BindEnv("wae_dataset_prefix")
290+
viper.SetDefault("wae_dataset_prefix", "bc_worker_metrics_")
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)