writekit/internal/cloudflare/analytics.go

452 lines
10 KiB
Go
Raw Normal View History

2026-01-09 00:16:46 +02:00
package cloudflare
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"sync"
"time"
)
type Client struct {
apiToken string
zoneID string
client *http.Client
cache *analyticsCache
}
type analyticsCache struct {
mu sync.RWMutex
data map[string]*cachedResult
ttl time.Duration
}
type cachedResult struct {
result *ZoneAnalytics
expiresAt time.Time
}
func NewClient() *Client {
return &Client{
apiToken: os.Getenv("CLOUDFLARE_API_TOKEN"),
zoneID: os.Getenv("CLOUDFLARE_ZONE_ID"),
client: &http.Client{Timeout: 30 * time.Second},
cache: &analyticsCache{
data: make(map[string]*cachedResult),
ttl: 5 * time.Minute,
},
}
}
func (c *Client) IsConfigured() bool {
return c.apiToken != "" && c.zoneID != ""
}
type ZoneAnalytics struct {
TotalRequests int64 `json:"totalRequests"`
TotalPageViews int64 `json:"totalPageViews"`
UniqueVisitors int64 `json:"uniqueVisitors"`
TotalBandwidth int64 `json:"totalBandwidth"`
Daily []DailyStats `json:"daily"`
Browsers []NamedCount `json:"browsers"`
OS []NamedCount `json:"os"`
Devices []NamedCount `json:"devices"`
Countries []NamedCount `json:"countries"`
Paths []PathStats `json:"paths"`
}
type DailyStats struct {
Date string `json:"date"`
Requests int64 `json:"requests"`
PageViews int64 `json:"pageViews"`
Visitors int64 `json:"visitors"`
Bandwidth int64 `json:"bandwidth"`
}
type NamedCount struct {
Name string `json:"name"`
Count int64 `json:"count"`
}
type PathStats struct {
Path string `json:"path"`
Requests int64 `json:"requests"`
}
type graphqlRequest struct {
Query string `json:"query"`
Variables map[string]any `json:"variables,omitempty"`
}
type graphqlResponse struct {
Data json.RawMessage `json:"data"`
Errors []struct {
Message string `json:"message"`
} `json:"errors,omitempty"`
}
func (c *Client) GetAnalytics(ctx context.Context, days int, hostname string) (*ZoneAnalytics, error) {
if !c.IsConfigured() {
return nil, fmt.Errorf("cloudflare not configured")
}
cacheKey := fmt.Sprintf("%s:%d:%s", c.zoneID, days, hostname)
if cached := c.cache.get(cacheKey); cached != nil {
return cached, nil
}
since := time.Now().AddDate(0, 0, -days).Format("2006-01-02")
until := time.Now().Format("2006-01-02")
result := &ZoneAnalytics{}
dailyData, err := c.fetchDailyStats(ctx, since, until, hostname)
if err != nil {
return nil, err
}
result.Daily = dailyData
for _, d := range dailyData {
result.TotalRequests += d.Requests
result.TotalPageViews += d.PageViews
result.UniqueVisitors += d.Visitors
result.TotalBandwidth += d.Bandwidth
}
browsers, _ := c.fetchGroupedStats(ctx, since, until, hostname, "clientRequestHTTPHost", "userAgentBrowser")
result.Browsers = browsers
osStats, _ := c.fetchGroupedStats(ctx, since, until, hostname, "clientRequestHTTPHost", "userAgentOS")
result.OS = osStats
devices, _ := c.fetchGroupedStats(ctx, since, until, hostname, "clientRequestHTTPHost", "deviceType")
result.Devices = devices
countries, _ := c.fetchGroupedStats(ctx, since, until, hostname, "clientRequestHTTPHost", "clientCountryName")
result.Countries = countries
paths, _ := c.fetchPathStats(ctx, since, until, hostname)
result.Paths = paths
c.cache.set(cacheKey, result)
return result, nil
}
func (c *Client) fetchDailyStats(ctx context.Context, since, until, hostname string) ([]DailyStats, error) {
query := `
query ($zoneTag: String!, $since: Date!, $until: Date!, $hostname: String!) {
viewer {
zones(filter: { zoneTag: $zoneTag }) {
httpRequests1dGroups(
filter: { date_geq: $since, date_leq: $until, clientRequestHTTPHost: $hostname }
orderBy: [date_ASC]
limit: 100
) {
dimensions {
date
}
sum {
requests
pageViews
bytes
}
uniq {
uniques
}
}
}
}
}
`
vars := map[string]any{
"zoneTag": c.zoneID,
"since": since,
"until": until,
"hostname": hostname,
}
resp, err := c.doQuery(ctx, query, vars)
if err != nil {
return nil, err
}
var data struct {
Viewer struct {
Zones []struct {
HttpRequests1dGroups []struct {
Dimensions struct {
Date string `json:"date"`
} `json:"dimensions"`
Sum struct {
Requests int64 `json:"requests"`
PageViews int64 `json:"pageViews"`
Bytes int64 `json:"bytes"`
} `json:"sum"`
Uniq struct {
Uniques int64 `json:"uniques"`
} `json:"uniq"`
} `json:"httpRequests1dGroups"`
} `json:"zones"`
} `json:"viewer"`
}
if err := json.Unmarshal(resp, &data); err != nil {
return nil, fmt.Errorf("parse response: %w", err)
}
var results []DailyStats
if len(data.Viewer.Zones) > 0 {
for _, g := range data.Viewer.Zones[0].HttpRequests1dGroups {
results = append(results, DailyStats{
Date: g.Dimensions.Date,
Requests: g.Sum.Requests,
PageViews: g.Sum.PageViews,
Visitors: g.Uniq.Uniques,
Bandwidth: g.Sum.Bytes,
})
}
}
return results, nil
}
func (c *Client) fetchGroupedStats(ctx context.Context, since, until, hostname, hostField, groupBy string) ([]NamedCount, error) {
query := fmt.Sprintf(`
query ($zoneTag: String!, $since: Date!, $until: Date!, $hostname: String!) {
viewer {
zones(filter: { zoneTag: $zoneTag }) {
httpRequests1dGroups(
filter: { date_geq: $since, date_leq: $until, %s: $hostname }
orderBy: [sum_requests_DESC]
limit: 10
) {
dimensions {
%s
}
sum {
requests
}
}
}
}
}
`, hostField, groupBy)
vars := map[string]any{
"zoneTag": c.zoneID,
"since": since,
"until": until,
"hostname": hostname,
}
resp, err := c.doQuery(ctx, query, vars)
if err != nil {
return nil, err
}
var data struct {
Viewer struct {
Zones []struct {
HttpRequests1dGroups []struct {
Dimensions map[string]string `json:"dimensions"`
Sum struct {
Requests int64 `json:"requests"`
} `json:"sum"`
} `json:"httpRequests1dGroups"`
} `json:"zones"`
} `json:"viewer"`
}
if err := json.Unmarshal(resp, &data); err != nil {
return nil, fmt.Errorf("parse response: %w", err)
}
var results []NamedCount
if len(data.Viewer.Zones) > 0 {
for _, g := range data.Viewer.Zones[0].HttpRequests1dGroups {
name := g.Dimensions[groupBy]
if name == "" {
name = "Unknown"
}
results = append(results, NamedCount{
Name: name,
Count: g.Sum.Requests,
})
}
}
return results, nil
}
func (c *Client) fetchPathStats(ctx context.Context, since, until, hostname string) ([]PathStats, error) {
query := `
query ($zoneTag: String!, $since: Date!, $until: Date!, $hostname: String!) {
viewer {
zones(filter: { zoneTag: $zoneTag }) {
httpRequests1dGroups(
filter: { date_geq: $since, date_leq: $until, clientRequestHTTPHost: $hostname }
orderBy: [sum_requests_DESC]
limit: 20
) {
dimensions {
clientRequestPath
}
sum {
requests
}
}
}
}
}
`
vars := map[string]any{
"zoneTag": c.zoneID,
"since": since,
"until": until,
"hostname": hostname,
}
resp, err := c.doQuery(ctx, query, vars)
if err != nil {
return nil, err
}
var data struct {
Viewer struct {
Zones []struct {
HttpRequests1dGroups []struct {
Dimensions struct {
Path string `json:"clientRequestPath"`
} `json:"dimensions"`
Sum struct {
Requests int64 `json:"requests"`
} `json:"sum"`
} `json:"httpRequests1dGroups"`
} `json:"zones"`
} `json:"viewer"`
}
if err := json.Unmarshal(resp, &data); err != nil {
return nil, fmt.Errorf("parse response: %w", err)
}
var results []PathStats
if len(data.Viewer.Zones) > 0 {
for _, g := range data.Viewer.Zones[0].HttpRequests1dGroups {
results = append(results, PathStats{
Path: g.Dimensions.Path,
Requests: g.Sum.Requests,
})
}
}
return results, nil
}
func (c *Client) doQuery(ctx context.Context, query string, vars map[string]any) (json.RawMessage, error) {
reqBody := graphqlRequest{
Query: query,
Variables: vars,
}
body, err := json.Marshal(reqBody)
if err != nil {
return nil, err
}
req, err := http.NewRequestWithContext(ctx, "POST", "https://api.cloudflare.com/client/v4/graphql", bytes.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+c.apiToken)
resp, err := c.client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("cloudflare API error: %s", string(respBody))
}
var gqlResp graphqlResponse
if err := json.Unmarshal(respBody, &gqlResp); err != nil {
return nil, err
}
if len(gqlResp.Errors) > 0 {
return nil, fmt.Errorf("graphql error: %s", gqlResp.Errors[0].Message)
}
return gqlResp.Data, nil
}
func (c *analyticsCache) get(key string) *ZoneAnalytics {
c.mu.RLock()
defer c.mu.RUnlock()
if cached, ok := c.data[key]; ok && time.Now().Before(cached.expiresAt) {
return cached.result
}
return nil
}
func (c *analyticsCache) set(key string, result *ZoneAnalytics) {
c.mu.Lock()
defer c.mu.Unlock()
c.data[key] = &cachedResult{
result: result,
expiresAt: time.Now().Add(c.ttl),
}
}
func (c *Client) PurgeURLs(ctx context.Context, urls []string) error {
if !c.IsConfigured() || len(urls) == 0 {
return nil
}
body, err := json.Marshal(map[string][]string{"files": urls})
if err != nil {
return err
}
req, err := http.NewRequestWithContext(ctx, "POST",
fmt.Sprintf("https://api.cloudflare.com/client/v4/zones/%s/purge_cache", c.zoneID),
bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+c.apiToken)
resp, err := c.client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
respBody, _ := io.ReadAll(resp.Body)
return fmt.Errorf("cloudflare purge failed: %s", string(respBody))
}
return nil
}