Skip to content

Commit 53c5fd9

Browse files
authored
Merge branch 'master' into add-asn-metrics
2 parents b2678a7 + f3d930d commit 53c5fd9

6 files changed

Lines changed: 270 additions & 6 deletions

File tree

README.md

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,10 @@ Authentication towards the Cloudflare API can be done in two ways:
2626
The preferred way of authenticating is with an API token, for which the scope can be configured at the Cloudflare
2727
dashboard.
2828

29+
**Important**: Cloudflare supports two types of API tokens:
30+
- **User-level tokens**: Can access all accounts the user has permissions for. These tokens auto-discover all accessible accounts.
31+
- **Account-scoped tokens**: Scoped to a specific account. When using account-scoped tokens, you **must** set the `CF_ACCOUNTS` environment variable with your account ID(s).
32+
2933
Required authentication scopes:
3034

3135
- `Zone/Analytics:Read` is required for zone-level metrics
@@ -37,9 +41,15 @@ Required authentication scopes:
3741
- `Account:Load Balancing: Monitors and Pools:Read` is required to fetch pools origin health status `cloudflare_pool_origin_health_status` metric
3842
- `Cloudflare Tunnel Read` is required to fetch Cloudflare Tunnel (Cloudflare Zero Trust) metrics
3943

40-
To authenticate this way, only set `CF_API_TOKEN` (omit `CF_API_EMAIL` and `CF_API_KEY`)
44+
**To authenticate with a user-level token**:
45+
- Set `CF_API_TOKEN` (omit `CF_API_EMAIL` and `CF_API_KEY`)
46+
- The exporter will auto-discover all accounts you have access to
47+
- [Shortcut to create the API token](https://dash.cloudflare.com/profile/api-tokens?permissionGroupKeys=%5B%7B%22key%22%3A%22account_analytics%22%2C%22type%22%3A%22read%22%7D%2C%7B%22key%22%3A%22account_settings%22%2C%22type%22%3A%22read%22%7D%2C%7B%22key%22%3A%22analytics%22%2C%22type%22%3A%22read%22%7D%2C%7B%22key%22%3A%22firewall_services%22%2C%22type%22%3A%22read%22%7D%5D&name=Cloudflare+Exporter&accountId=*&zoneId=all)
4148

42-
[Shortcut to create the API token](https://dash.cloudflare.com/profile/api-tokens?permissionGroupKeys=%5B%7B%22key%22%3A%22account_analytics%22%2C%22type%22%3A%22read%22%7D%2C%7B%22key%22%3A%22account_settings%22%2C%22type%22%3A%22read%22%7D%2C%7B%22key%22%3A%22analytics%22%2C%22type%22%3A%22read%22%7D%2C%7B%22key%22%3A%22firewall_services%22%2C%22type%22%3A%22read%22%7D%5D&name=Cloudflare+Exporter&accountId=*&zoneId=all)
49+
**To authenticate with an account-scoped token**:
50+
- Set `CF_API_TOKEN` with your account-scoped token
51+
- Set `CF_ACCOUNTS` with your account ID (find it in the Cloudflare dashboard URL: `https://dash.cloudflare.com/<ACCOUNT_ID>/...`)
52+
- Example: `CF_ACCOUNTS=abc123def456` or for multiple accounts: `CF_ACCOUNTS=abc123,def456`
4353

4454
### User email + API key
4555

@@ -57,6 +67,7 @@ The exporter can be configured using env variables or command flags.
5767
| `CF_API_EMAIL` | user email (see <https://support.cloudflare.com/hc/en-us/articles/200167836-Managing-API-Tokens-and-Keys>) |
5868
| `CF_API_KEY` | API key associated with email (`CF_API_EMAIL` is required if this is set)|
5969
| `CF_API_TOKEN` | API authentication token (recommended before API key + email. Version 0.0.5+. see <https://developers.cloudflare.com/analytics/graphql-api/getting-started/authentication/api-token-auth>) |
70+
| `CF_ACCOUNTS` | (Required for account-scoped tokens) Cloudflare account IDs to monitor, comma delimited list. When using account-scoped API tokens, this must be set. User-level tokens can omit this to auto-discover all accessible accounts. |
6071
| `CF_ZONES` | (Optional) cloudflare zones to export, comma delimited list of zone ids. If not set, all zones from account are exported |
6172
| `CF_EXCLUDE_ZONES` | (Optional) cloudflare zones to exclude, comma delimited list of zone ids. If not set, no zones from account are excluded |
6273
| `CF_TIMEOUT` | Set cloudflare request timeout. Default 10 seconds |
@@ -67,6 +78,7 @@ The exporter can be configured using env variables or command flags.
6778
| `SCRAPE_INTERVAL` | scrape interval in seconds (will query cloudflare every SCRAPE_INTERVAL seconds), default `60` |
6879
| `METRICS_DENYLIST` | (Optional) cloudflare-exporter metrics to not export, comma delimited list of cloudflare-exporter metrics. If not set, all metrics are exported |
6980
| `ENABLE_PPROF` | (Optional) enable pprof profiling endpoints at `/debug/pprof/`. Accepts `true` or `false`, default `false`. **Warning**: Only enable in development/debugging environments |
81+
| `ENABLE_EDGE_ERRORS_BY_PATH` | (Optional) enable edge errors by path metric. Accepts `true` or `false`, default `false`. See [Edge Errors by Path Metric](#edge-errors-by-path-metric-opt-in) |
7082
| `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. |
7183
| `LOG_LEVEL` | Set loglevel. Options are error, warn, info, debug. default `error` |
7284

@@ -76,6 +88,7 @@ Corresponding flags:
7688
-cf_api_email="": cloudflare api email, works with api_key flag
7789
-cf_api_key="": cloudflare api key, works with api_email flag
7890
-cf_api_token="": cloudflare api token (version 0.0.5+, preferred)
91+
-cf_accounts="": cloudflare accounts to monitor, comma delimited list (required for account-scoped API tokens)
7992
-cf_zones="": cloudflare zones to export, comma delimited list
8093
-cf_exclude_zones="": cloudflare zones to exclude, comma delimited list
8194
-cf_timeout="10s": cloudflare request timeout, default 10 seconds
@@ -86,6 +99,7 @@ Corresponding flags:
8699
-scrape_interval=60: scrape interval in seconds, defaults to 60
87100
-metrics_denylist="": cloudflare-exporter metrics to not export, comma delimited list
88101
-enable_pprof=false: enable pprof profiling endpoints at /debug/pprof/
102+
-enable_edge_errors_by_path=false: enable edge errors by path metric (high cardinality, opt-in)
89103
-log_level="error": log level(error,warn,info,debug)
90104
```
91105

@@ -119,6 +133,7 @@ Note: `ZONE_<name>` configuration is not supported as flag.
119133
# HELP cloudflare_zone_requests_status_country_host Count of requests for zone per edge HTTP status per country per host
120134
# HELP cloudflare_zone_requests_browser_map_page_views_count Number of successful requests for HTML pages per zone
121135
# HELP cloudflare_zone_requests_total Number of requests for zone
136+
# HELP cloudflare_zone_edge_errors_by_path Number of edge errors (4xx and 5xx) by request path
122137
# HELP cloudflare_zone_threats_country Threats per zone per country
123138
# HELP cloudflare_zone_threats_total Threats per zone
124139
# HELP cloudflare_zone_uniques_total Uniques per zone
@@ -131,6 +146,14 @@ Note: `ZONE_<name>` configuration is not supported as flag.
131146
# HELP cloudflare_r2_storage_total_bytes Total storage used by R2
132147
```
133148

149+
### Edge Errors by Path Metric (Opt-in)
150+
151+
The `cloudflare_zone_edge_errors_by_path` metric tracks edge errors (4xx/5xx) by request path. This enables path-based filtering in alerts to exclude known-noisy endpoints while catching real issues.
152+
153+
**Disabled by default** due to high cardinality. Enable with `ENABLE_EDGE_ERRORS_BY_PATH=true`.
154+
155+
Paths are normalized to reduce cardinality (e.g., `/users/123``/users/:id`, UUIDs → `:uuid`).
156+
134157
## Helm chart repository
135158

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

cloudflare.go

Lines changed: 95 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,24 @@ type zoneRespASN struct {
112112
}
113113

114114

115+
type cloudflareResponseEdgeErrorsByPath struct {
116+
Viewer struct {
117+
Zones []zoneRespEdgeErrorsByPath `json:"zones"`
118+
} `json:"viewer"`
119+
}
120+
121+
type zoneRespEdgeErrorsByPath struct {
122+
ZoneTag string `json:"zoneTag"`
123+
HTTPRequestsAdaptiveGroups []struct {
124+
Count uint64 `json:"count"`
125+
Dimensions struct {
126+
EdgeResponseStatus uint16 `json:"edgeResponseStatus"`
127+
ClientRequestHTTPHost string `json:"clientRequestHTTPHost"`
128+
ClientRequestPath string `json:"clientRequestPath"`
129+
} `json:"dimensions"`
130+
} `json:"httpRequestsAdaptiveGroups"`
131+
}
132+
115133
type logpushResponse struct {
116134
LogpushHealthAdaptiveGroups []struct {
117135
Count uint64 `json:"count"`
@@ -467,8 +485,37 @@ func fetchFirewallRules(zoneID string) map[string]string {
467485
return firewallRulesMap
468486
}
469487

470-
func fetchAccounts() []cfaccounts.Account {
488+
func fetchAccounts(targetAccountIDs []string) []cfaccounts.Account {
471489
var cfAccounts []cfaccounts.Account
490+
491+
if len(targetAccountIDs) > 0 {
492+
log.Info("Using provided account IDs (account-scoped token mode)")
493+
for _, accountID := range targetAccountIDs {
494+
accountID = strings.TrimSpace(accountID)
495+
if accountID == "" {
496+
continue
497+
}
498+
499+
ctx, cancel := context.WithTimeout(context.Background(), cftimeout)
500+
account, err := cfclient.Accounts.Get(ctx, cfaccounts.AccountGetParams{
501+
AccountID: cf.F(accountID),
502+
})
503+
cancel()
504+
505+
if err != nil {
506+
log.Warnf("Account %s details unavailable, using ID only: %v", accountID, err)
507+
cfAccounts = append(cfAccounts, cfaccounts.Account{
508+
ID: accountID,
509+
})
510+
} else {
511+
cfAccounts = append(cfAccounts, *account)
512+
}
513+
}
514+
log.Infof("Loaded %d account(s) from CF_ACCOUNTS", len(cfAccounts))
515+
return cfAccounts
516+
}
517+
518+
log.Info("Listing all accessible accounts (user-level token mode)")
472519
ctx, cancel := context.WithTimeout(context.Background(), cftimeout)
473520
defer cancel()
474521
page := cfclient.Accounts.ListAutoPaging(ctx,
@@ -888,6 +935,53 @@ func fetchLogpushZone(zoneIDs []string) (*cloudflareResponseLogpushZone, error)
888935
return &resp, nil
889936
}
890937

938+
func fetchEdgeErrorsByPath(zoneIDs []string) (*cloudflareResponseEdgeErrorsByPath, error) {
939+
request := graphql.NewRequest(`
940+
query ($zoneIDs: [String!], $mintime: Time!, $maxtime: Time!, $limit: Int!) {
941+
viewer {
942+
zones(filter: { zoneTag_in: $zoneIDs }) {
943+
zoneTag
944+
httpRequestsAdaptiveGroups(
945+
limit: $limit
946+
filter: {
947+
datetime_geq: $mintime
948+
datetime_lt: $maxtime
949+
edgeResponseStatus_geq: 400
950+
}
951+
) {
952+
count
953+
dimensions {
954+
edgeResponseStatus
955+
clientRequestHTTPHost
956+
clientRequestPath
957+
}
958+
}
959+
}
960+
}
961+
}
962+
`)
963+
964+
now, now1mAgo := GetTimeRange()
965+
request.Var("limit", gqlQueryLimit)
966+
request.Var("maxtime", now)
967+
request.Var("mintime", now1mAgo)
968+
request.Var("zoneIDs", zoneIDs)
969+
970+
gql.Mu.RLock()
971+
defer gql.Mu.RUnlock()
972+
973+
ctx, cancel := context.WithTimeout(context.Background(), cftimeout)
974+
defer cancel()
975+
976+
var resp cloudflareResponseEdgeErrorsByPath
977+
if err := gql.Client.Run(ctx, request, &resp); err != nil {
978+
log.Errorf("failed to fetch edge errors by path, err:%v", err)
979+
return nil, err
980+
}
981+
982+
return &resp, nil
983+
}
984+
891985
func fetchR2Account(accountID string) (*cloudflareResponseR2Account, error) {
892986
request := graphql.NewRequest(`query($accountID: String!, $limit: Int!, $date: String!) {
893987
viewer {

main.go

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,15 @@ var (
3939
// cfgMetricsDenylist = ""
4040
// )
4141

42+
func getTargetAccounts() []string {
43+
var accountIDs []string
44+
45+
if len(viper.GetString("cf_accounts")) > 0 {
46+
accountIDs = strings.Split(viper.GetString("cf_accounts"), ",")
47+
}
48+
return accountIDs
49+
}
50+
4251
func getTargetZones() []string {
4352
var zoneIDs []string
4453

@@ -105,7 +114,8 @@ func filterExcludedZones(all []cfzones.Zone, exclude []string) []cfzones.Zone {
105114

106115
func fetchMetrics() {
107116
var wg sync.WaitGroup
108-
accounts := fetchAccounts()
117+
targetAccounts := getTargetAccounts()
118+
accounts := fetchAccounts(targetAccounts)
109119

110120
for _, a := range accounts {
111121
wg.Add(1)
@@ -149,6 +159,9 @@ func fetchMetrics() {
149159

150160
wg.Add(1)
151161
go fetchZoneASNAnalytics(filteredZones, &wg)
162+
163+
wg.Add(1)
164+
go fetchEdgeErrorsByPathAnalytics(filteredZones, &wg)
152165
} else if zoneCount > cfgraphqlreqlimit {
153166
for s := 0; s < zoneCount; s += cfgraphqlreqlimit {
154167
e := s + cfgraphqlreqlimit
@@ -169,6 +182,9 @@ func fetchMetrics() {
169182

170183
wg.Add(1)
171184
go fetchZoneASNAnalytics(filteredZones[s:e], &wg)
185+
186+
wg.Add(1)
187+
go fetchEdgeErrorsByPathAnalytics(filteredZones[s:e], &wg)
172188
}
173189
}
174190

@@ -257,6 +273,10 @@ func main() {
257273
flags.String("cf_api_token", "", "cloudflare api token (preferred)")
258274
viper.BindEnv("cf_api_token")
259275

276+
flags.String("cf_accounts", "", "cloudflare accounts to monitor, comma delimited list of account ids (required for account-scoped API tokens)")
277+
viper.BindEnv("cf_accounts")
278+
viper.SetDefault("cf_accounts", "")
279+
260280
flags.String("cf_zones", "", "cloudflare zones to export, comma delimited list of zone ids")
261281
viper.BindEnv("cf_zones")
262282
viper.SetDefault("cf_zones", "")
@@ -293,6 +313,10 @@ func main() {
293313
viper.BindEnv("enable_pprof")
294314
viper.SetDefault("enable_pprof", false)
295315

316+
flags.Bool("enable_edge_errors_by_path", false, "enable edge errors by path metric (high cardinality)")
317+
viper.BindEnv("enable_edge_errors_by_path")
318+
viper.SetDefault("enable_edge_errors_by_path", false)
319+
296320
viper.BindPFlags(flags)
297321

298322
logLevel := viper.GetString("log_level")

prometheus.go

Lines changed: 63 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -63,8 +63,9 @@ const (
6363
tunnelHealthStatusMetricName MetricName = "cloudflare_tunnel_health_status"
6464
tunnelConnectorInfoMetricName MetricName = "cloudflare_tunnel_connector_info"
6565
tunnelConnectorActiveConnectionsMetricName MetricName = "cloudflare_tunnel_connector_active_connections"
66-
zoneRequestASNMetricName MetricName = "cloudflare_zone_requests_asn"
67-
zoneBandwidthASNMetricName MetricName = "cloudflare_zone_bandwidth_asn"
66+
zoneRequestASNMetricName MetricName = "cloudflare_zone_requests_asn"
67+
zoneBandwidthASNMetricName MetricName = "cloudflare_zone_bandwidth_asn"
68+
zoneEdgeErrorsByPathMetricName MetricName = "cloudflare_zone_edge_errors_by_path"
6869
)
6970

7071
type MetricsSet map[MetricName]struct{}
@@ -346,6 +347,11 @@ var (
346347
Name: zoneBandwidthASNMetricName.String(),
347348
Help: "Bandwidth per ASN (Autonomous System Number) in bytes",
348349
}, []string{"zone", "account", "asn", "asn_description"})
350+
351+
zoneEdgeErrorsByPath = prometheus.NewCounterVec(prometheus.CounterOpts{
352+
Name: zoneEdgeErrorsByPathMetricName.String(),
353+
Help: "Number of edge errors (4xx and 5xx) by request path",
354+
}, []string{"zone", "account", "status", "host", "path"})
349355
)
350356

351357
func buildAllMetricsSet() MetricsSet {
@@ -358,6 +364,9 @@ func buildAllMetricsSet() MetricsSet {
358364
allMetricsSet.Add(zoneRequestHTTPStatusMetricName)
359365
allMetricsSet.Add(zoneRequestBrowserMapMetricName)
360366
allMetricsSet.Add(zoneRequestOriginStatusCountryHostMetricName)
367+
allMetricsSet.Add(zoneRequestOriginStatusCountryHostP50MetricName)
368+
allMetricsSet.Add(zoneRequestOriginStatusCountryHostP95MetricName)
369+
allMetricsSet.Add(zoneRequestOriginStatusCountryHostP99MetricName)
361370
allMetricsSet.Add(zoneRequestStatusCountryHostMetricName)
362371
allMetricsSet.Add(zoneBandwidthTotalMetricName)
363372
allMetricsSet.Add(zoneBandwidthCachedMetricName)
@@ -391,6 +400,7 @@ func buildAllMetricsSet() MetricsSet {
391400
allMetricsSet.Add(tunnelConnectorActiveConnectionsMetricName)
392401
allMetricsSet.Add(zoneRequestASNMetricName)
393402
allMetricsSet.Add(zoneBandwidthASNMetricName)
403+
allMetricsSet.Add(zoneEdgeErrorsByPathMetricName)
394404
return allMetricsSet
395405
}
396406

@@ -542,6 +552,9 @@ func mustRegisterMetrics(deniedMetrics MetricsSet) {
542552
}
543553
if !deniedMetrics.Has(zoneBandwidthASNMetricName) {
544554
prometheus.MustRegister(zoneBandwidthASN)
555+
}
556+
if !deniedMetrics.Has(zoneEdgeErrorsByPathMetricName) {
557+
prometheus.MustRegister(zoneEdgeErrorsByPath)
545558
}
546559
}
547560

@@ -923,6 +936,54 @@ func addHTTPAdaptiveGroups(z *zoneResp, name string, account string) {
923936
}
924937
}
925938

939+
func fetchEdgeErrorsByPathAnalytics(zones []cfzones.Zone, wg *sync.WaitGroup) {
940+
defer wg.Done()
941+
942+
if !viper.GetBool("enable_edge_errors_by_path") {
943+
return
944+
}
945+
946+
if viper.GetBool("free_tier") {
947+
return
948+
}
949+
950+
zoneIDs := extractZoneIDs(zones)
951+
if len(zoneIDs) == 0 {
952+
return
953+
}
954+
955+
r, err := fetchEdgeErrorsByPath(zoneIDs)
956+
if err != nil {
957+
log.Error("failed to fetch edge errors by path: ", err)
958+
return
959+
}
960+
961+
for _, z := range r.Viewer.Zones {
962+
name, account := findZoneAccountName(zones, z.ZoneTag)
963+
addEdgeErrorsByPath(&z, name, account)
964+
}
965+
}
966+
967+
func addEdgeErrorsByPath(z *zoneRespEdgeErrorsByPath, name string, account string) {
968+
if len(z.HTTPRequestsAdaptiveGroups) == 0 {
969+
return
970+
}
971+
972+
label := prometheus.Labels{"zone": name, "account": account}
973+
zoneEdgeErrorsByPath.DeletePartialMatch(label)
974+
975+
for _, g := range z.HTTPRequestsAdaptiveGroups {
976+
zoneEdgeErrorsByPath.With(
977+
prometheus.Labels{
978+
"zone": name,
979+
"account": account,
980+
"status": strconv.Itoa(int(g.Dimensions.EdgeResponseStatus)),
981+
"host": g.Dimensions.ClientRequestHTTPHost,
982+
"path": normalizePath(g.Dimensions.ClientRequestPath),
983+
}).Add(float64(g.Count))
984+
}
985+
}
986+
926987
func fetchLoadBalancerAnalytics(zones []cfzones.Zone, wg *sync.WaitGroup) {
927988
defer wg.Done()
928989

0 commit comments

Comments
 (0)