Repository: cloudflare/cloudflare-go
Pull request #1348 (2023-08-02 03:01:01)

Web Analytics support
## Description

Add Web Analytics Support.
CC @cf-ypark

## Has your change been tested?
Unit tests have been added that align with the documented API spec.
I have locally tested the changes with a personal zone.

## Screenshots (if appropriate):

## Types of changes

What sort of change does your code introduce/modify?

- [ ] Bug fix (non-breaking change which fixes an issue)
- [x] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing functionality to change)

## Checklist:

- [x] My code follows the code style of this project.
- [ ] My change requires a change to the documentation.
- [ ] I have updated the documentation accordingly.
- [x] I have added tests to cover my changes.
- [x] All new and existing tests passed.
- [x] This change is using publicly documented in [cloudflare/api-schemas](https://github.com/cloudflare/api-schemas) 
      and relies on stable APIs. 

Public API documentation is available: https://developers.cloudflare.com/api/operations/web-analytics-create-site

gitguardian[bot]: #### ⚠️ GitGuardian has uncovered 1 secret following the scan of your pull request.

Please consider investigating the findings and remediating the incidents. Failure to do so may lead to compromising the associated services or software components.

<details>
<summary>🔎 Detected hardcoded secret in your pull request</summary>
<br>

| GitGuardian id | Secret                    | Commit           | Filename        |                      |
| -------------- | ------------------------- | ---------------- | --------------- | -------------------- |
| [-](https://dashboard.gitguardian.com/incidents/secrets) | Generic High Entropy Secret | 7ce0a6cff5f65658e3e4d17a1595184d298f8c2e | web_analytics_test.go | [View secret](https://github.com/cloudflare/cloudflare-go/commit/7ce0a6cff5f65658e3e4d17a1595184d298f8c2e#diff-cb27b3d892192e9af4df18af1cce4c38L16) |



</details>

<details>
<summary>🛠 Guidelines to remediate hardcoded secrets</summary>
<br>

1. Understand the implications of revoking this secret by investigating where it is used in your code.
2. Replace and store your secret safely. [Learn here](https://blog.gitguardian.com/secrets-api-management?utm_source=product&amp;utm_medium=GitHub_checks&amp;utm_campaign=check_run_comment) the best practices.
3. Revoke and [rotate this secret](https://docs.gitguardian.com/secrets-detection/detectors/generics/generic_high_entropy_secret#revoke-the-secret?utm_source=product&amp;utm_medium=GitHub_checks&amp;utm_campaign=check_run_comment).
4. If possible, [rewrite git history](https://blog.gitguardian.com/rewriting-git-history-cheatsheet?utm_source=product&amp;utm_medium=GitHub_checks&amp;utm_campaign=check_run_comment). Rewriting git history is not a trivial act. You might completely break other contributing developers' workflow and you risk accidentally deleting legitimate data.

To avoid such incidents in the future consider

-   following these [best practices](https://blog.gitguardian.com/secrets-api-management/?utm_source=product&amp;utm_medium=GitHub_checks&amp;utm_campaign=check_run_comment) for managing and storing secrets including API keys and other credentials
-   install [secret detection on pre-commit](https://docs.gitguardian.com/ggshield-docs/integrations/git-hooks/pre-commit?utm_source=product&amp;utm_medium=GitHub_checks&amp;utm_campaign=check_run_comment) to catch secret before it leaves your machine and ease remediation.


</details>

---

<sup>🦉 [GitGuardian](https://dashboard.gitguardian.com/auth/login/?utm_medium=checkruns&amp;utm_source=github&amp;utm_campaign=cr1) detects secrets in your source code to help developers and security teams secure the modern development process. You are seeing this because you or someone else with access to this repository has authorized GitGuardian to scan your pull request.<br/><br/>Our GitHub checks need improvements? [Share your feedbacks](https://050127dayho.typeform.com/to/KmeAPTMk)!</sup>
github-actions[bot]: Oops! It looks like no changelog entry is attached to this PR. Please include a release note as described in https://github.com/cloudflare/cloudflare-go/blob/master/docs/changelog-process.md.

Example: 

~~~
```release-note:TYPE
Release note
```
~~~

If you do not require a release note to be included, please add the `workflow/skip-changelog-entry` label.
broswen: > ####  GitGuardian has uncovered 1 secret following the scan of your pull request.
> Please consider investigating the findings and remediating the incidents. Failure to do so may lead to compromising the associated services or software components.
> 
>  Detected hardcoded secret in your pull request
>  Guidelines to remediate hardcoded secrets
> owl [GitGuardian](https://dashboard.gitguardian.com/auth/login/?utm_medium=checkruns&utm_source=github&utm_campaign=cr1) detects secrets in your source code to help developers and security teams secure the modern development process. You are seeing this because you or someone else with access to this repository has authorized GitGuardian to scan your pull request.Our GitHub checks need improvements? [Share your feedbacks](https://050127dayho.typeform.com/to/KmeAPTMk)!

All of the tokens/ids are random and not from an actual account.
jacobbednarz: yeah, ignore the GitGuardian stuff. it's really blunt at finding "issues". 
codecov-commenter: ## [Codecov](https://app.codecov.io/gh/cloudflare/cloudflare-go/pull/1348?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=cloudflare) Report
> Merging [#1348](https://app.codecov.io/gh/cloudflare/cloudflare-go/pull/1348?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=cloudflare) (1cd322e) into [master](https://app.codecov.io/gh/cloudflare/cloudflare-go/commit/b9ac80404771c061ccb53da6a2e13c41728a1d82?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=cloudflare) (b9ac804) will **increase** coverage by `0.12%`.
> Report is 102 commits behind head on master.
> The diff coverage is `52.96%`.

```diff
@@            Coverage Diff             @@
##           master    #1348      +/-   ##
==========================================
+ Coverage   48.33%   48.45%   +0.12%     
==========================================
  Files         133      136       +3     
  Lines       13023    13372     +349     
==========================================
+ Hits         6295     6480     +185     
- Misses       5201     5309     +108     
- Partials     1527     1583      +56     
```


| [Files Changed](https://app.codecov.io/gh/cloudflare/cloudflare-go/pull/1348?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=cloudflare) | Coverage Δ | |
|---|---|---|
| [device\_posture\_rule.go](https://app.codecov.io/gh/cloudflare/cloudflare-go/pull/1348?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=cloudflare#diff-ZGV2aWNlX3Bvc3R1cmVfcnVsZS5nbw==) | `61.80% <ø> (ø)` | |
| [load\_balancing.go](https://app.codecov.io/gh/cloudflare/cloudflare-go/pull/1348?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=cloudflare#diff-bG9hZF9iYWxhbmNpbmcuZ28=) | `59.40% <ø> (ø)` | |
| [teams\_accounts.go](https://app.codecov.io/gh/cloudflare/cloudflare-go/pull/1348?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=cloudflare#diff-dGVhbXNfYWNjb3VudHMuZ28=) | `53.84% <ø> (ø)` | |
| [rulesets.go](https://app.codecov.io/gh/cloudflare/cloudflare-go/pull/1348?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=cloudflare#diff-cnVsZXNldHMuZ28=) | `30.14% <22.58%> (-4.50%)` | :arrow_down: |
| [regional\_tiered\_cache.go](https://app.codecov.io/gh/cloudflare/cloudflare-go/pull/1348?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=cloudflare#diff-cmVnaW9uYWxfdGllcmVkX2NhY2hlLmdv) | `43.75% <43.75%> (ø)` | |
| [web\_analytics.go](https://app.codecov.io/gh/cloudflare/cloudflare-go/pull/1348?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=cloudflare#diff-d2ViX2FuYWx5dGljcy5nbw==) | `45.29% <45.29%> (ø)` | |
| [logpush.go](https://app.codecov.io/gh/cloudflare/cloudflare-go/pull/1348?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=cloudflare#diff-bG9ncHVzaC5nbw==) | `51.81% <46.15%> (-0.30%)` | :arrow_down: |
| [images.go](https://app.codecov.io/gh/cloudflare/cloudflare-go/pull/1348?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=cloudflare#diff-aW1hZ2VzLmdv) | `44.70% <47.82%> (-0.12%)` | :arrow_down: |
| [pages\_deployment.go](https://app.codecov.io/gh/cloudflare/cloudflare-go/pull/1348?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=cloudflare#diff-cGFnZXNfZGVwbG95bWVudC5nbw==) | `38.88% <61.76%> (+4.64%)` | :arrow_up: |
| [workers\_bindings.go](https://app.codecov.io/gh/cloudflare/cloudflare-go/pull/1348?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=cloudflare#diff-d29ya2Vyc19iaW5kaW5ncy5nbw==) | `67.90% <71.05%> (+0.46%)` | :arrow_up: |
| ... and [4 more](https://app.codecov.io/gh/cloudflare/cloudflare-go/pull/1348?src=pr&el=tree-more&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=cloudflare) | |

... and [3 files with indirect coverage changes](https://app.codecov.io/gh/cloudflare/cloudflare-go/pull/1348/indirect-changes?src=pr&el=tree-more&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=cloudflare)



Patch:
diff --git a/.changelog/1348.txt b/.changelog/1348.txt
new file mode 100644
index 000000000..19eca269e
--- /dev/null
+++ b/.changelog/1348.txt
@@ -0,0 +1,3 @@
+```release-note:enhancement
+web_analytics: add support for web_analytics API
+```
diff --git a/web_analytics.go b/web_analytics.go
new file mode 100644
index 000000000..40e6d1bde
--- /dev/null
+++ b/web_analytics.go
@@ -0,0 +1,410 @@
+package cloudflare
+
+import (
+	"context"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"net/http"
+	"time"
+)
+
+var (
+	ErrMissingWebAnalyticsSiteTag     = errors.New("missing required web analytics site ID")
+	ErrMissingWebAnalyticsRulesetID   = errors.New("missing required web analytics ruleset ID")
+	ErrMissingWebAnalyticsRuleID      = errors.New("missing required web analytics rule ID")
+	ErrMissingWebAnalyticsSiteHost    = errors.New("missing required web analytics host or zone_tag")
+	ErrConflictingWebAnalyticSiteHost = errors.New("conflicting web analytics host and zone_tag, only one must be specified")
+)
+
+// listWebAnalyticsSitesDefaultPageSize represents the default per_pagesize of the API.
+var listWebAnalyticsSitesDefaultPageSize = 10
+
+// WebAnalyticsSite describes a Web Analytics Site object.
+type WebAnalyticsSite struct {
+	SiteTag   string     `json:"site_tag"`
+	SiteToken string     `json:"site_token"`
+	Created   *time.Time `json:"created,omitempty"`
+	// Snippet is an encoded JS script to insert into your site HTML.
+	Snippet string `json:"snippet"`
+	// AutoInstall defines whether Cloudflare will inject the JS snippet automatically for orange-clouded sites.
+	AutoInstall bool                `json:"auto_install"`
+	Ruleset     WebAnalyticsRuleset `json:"ruleset"`
+	Rules       []WebAnalyticsRule  `json:"rules"`
+}
+
+// WebAnalyticsRule describes a Web Analytics Rule object.
+type WebAnalyticsRule struct {
+	ID    string   `json:"id,omitempty"`
+	Host  string   `json:"host"`
+	Paths []string `json:"paths"`
+	// Inclusive defines whether the rule includes or excludes the matched traffic from being measured in web analytics.
+	Inclusive bool       `json:"inclusive"`
+	Created   *time.Time `json:"created,omitempty"`
+	// IsPaused defines whether the rule is paused (inactive) or not.
+	IsPaused bool `json:"is_paused"`
+	Priority int  `json:"priority,omitempty"`
+}
+
+// CreateWebAnalyticsRule describes the properties required to create or update a Web Analytics Rule object.
+type CreateWebAnalyticsRule struct {
+	ID    string   `json:"id,omitempty"`
+	Host  string   `json:"host"`
+	Paths []string `json:"paths"`
+	// Inclusive defines whether the rule includes or excludes the matched traffic from being measured in web analytics.
+	Inclusive bool `json:"inclusive"`
+	IsPaused  bool `json:"is_paused"`
+}
+
+// WebAnalyticsRuleset describes a Web Analytics Ruleset object.
+type WebAnalyticsRuleset struct {
+	ID       string `json:"id"`
+	ZoneTag  string `json:"zone_tag"`
+	ZoneName string `json:"zone_name"`
+	Enabled  bool   `json:"enabled"`
+}
+
+// WebAnalyticsSiteResponse is the API response, containing a single WebAnalyticsSite.
+type WebAnalyticsSiteResponse struct {
+	Response
+	Result WebAnalyticsSite `json:"result"`
+}
+
+// WebAnalyticsSitesResponse is the API response, containing an array of WebAnalyticsSite.
+type WebAnalyticsSitesResponse struct {
+	Response
+	ResultInfo ResultInfo         `json:"result_info"`
+	Result     []WebAnalyticsSite `json:"result"`
+}
+
+// WebAnalyticsRuleResponse is the API response, containing a single WebAnalyticsRule.
+type WebAnalyticsRuleResponse struct {
+	Response
+	Result WebAnalyticsRule `json:"result"`
+}
+
+type WebAnalyticsRulesetRules struct {
+	Ruleset WebAnalyticsRuleset `json:"ruleset"`
+	Rules   []WebAnalyticsRule  `json:"rules"`
+}
+
+// WebAnalyticsRulesResponse is the API response, containing a WebAnalyticsRuleset and array of WebAnalyticsRule.
+type WebAnalyticsRulesResponse struct {
+	Response
+	Result WebAnalyticsRulesetRules `json:"result"`
+}
+
+// WebAnalyticsIDResponse is the API response, containing a single ID.
+type WebAnalyticsIDResponse struct {
+	Response
+	Result struct {
+		ID string `json:"id"`
+	} `json:"result"`
+}
+
+// WebAnalyticsSiteTagResponse is the API response, containing a single ID.
+type WebAnalyticsSiteTagResponse struct {
+	Response
+	Result struct {
+		SiteTag string `json:"site_tag"`
+	} `json:"result"`
+}
+
+type CreateWebAnalyticsSiteParams struct {
+	// Host is the host to measure traffic for.
+	Host string `json:"host,omitempty"`
+	// ZoneTag is the zone tag to measure traffic for.
+	ZoneTag string `json:"zone_tag,omitempty"`
+	// AutoInstall defines whether Cloudflare will inject the JS snippet automatically for orange-clouded sites.
+	AutoInstall *bool `json:"auto_install"`
+}
+
+// CreateWebAnalyticsSite creates a new Web Analytics Site for an Account.
+//
+// API reference: https://api.cloudflare.com/#web-analytics-create-site
+func (api *API) CreateWebAnalyticsSite(ctx context.Context, rc *ResourceContainer, params CreateWebAnalyticsSiteParams) (*WebAnalyticsSite, error) {
+	if rc.Level != AccountRouteLevel {
+		return nil, ErrRequiredAccountLevelResourceContainer
+	}
+	if params.Host == "" && params.ZoneTag == "" {
+		return nil, ErrMissingWebAnalyticsSiteHost
+	}
+	if params.Host != "" && params.ZoneTag != "" {
+		return nil, ErrConflictingWebAnalyticSiteHost
+	}
+	if params.AutoInstall == nil {
+		// default auto_install to true for orange-clouded zones (zone_tag is specified)
+		params.AutoInstall = BoolPtr(params.ZoneTag != "")
+	}
+	uri := fmt.Sprintf("/accounts/%s/rum/site_info", rc.Identifier)
+	res, err := api.makeRequestContext(ctx, http.MethodPost, uri, params)
+	if err != nil {
+		return nil, err
+	}
+	var r WebAnalyticsSiteResponse
+	err = json.Unmarshal(res, &r)
+	if err != nil {
+		return nil, fmt.Errorf("%s: %w", errUnmarshalError, err)
+	}
+	return &r.Result, nil
+}
+
+type ListWebAnalyticsSitesParams struct {
+	ResultInfo
+	// Property to order Sites by, "host" or "created".
+	OrderBy string `url:"order_by,omitempty"`
+}
+
+// ListWebAnalyticsSites returns all Web Analytics Sites of an Account.
+//
+// API reference: https://api.cloudflare.com/#web-analytics-list-sites
+func (api *API) ListWebAnalyticsSites(ctx context.Context, rc *ResourceContainer, params ListWebAnalyticsSitesParams) ([]WebAnalyticsSite, *ResultInfo, error) {
+	if rc.Level != AccountRouteLevel {
+		return nil, nil, ErrRequiredAccountLevelResourceContainer
+	}
+
+	autoPaginate := true
+	if params.PerPage >= 1 || params.Page >= 1 {
+		autoPaginate = false
+	}
+	if params.PerPage < 1 {
+		params.PerPage = listWebAnalyticsSitesDefaultPageSize
+	}
+
+	if params.Page < 1 {
+		params.Page = 1
+	}
+
+	var sites []WebAnalyticsSite
+	var lastResultInfo ResultInfo
+
+	for {
+		uri := buildURI(fmt.Sprintf("/accounts/%s/rum/site_info/list", rc.Identifier), params)
+
+		res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil)
+		if err != nil {
+			return nil, nil, err
+		}
+		var r WebAnalyticsSitesResponse
+		err = json.Unmarshal(res, &r)
+		if err != nil {
+			return nil, nil, fmt.Errorf("%s: %w", errUnmarshalError, err)
+		}
+		sites = append(sites, r.Result...)
+		lastResultInfo = r.ResultInfo
+		params.ResultInfo = r.ResultInfo.Next()
+		if params.ResultInfo.Done() || !autoPaginate {
+			break
+		}
+	}
+	return sites, &lastResultInfo, nil
+}
+
+type GetWebAnalyticsSiteParams struct {
+	SiteTag string
+}
+
+// GetWebAnalyticsSite fetches detail about one Web Analytics Site for an Account.
+//
+// API reference: https://api.cloudflare.com/#web-analytics-get-site
+func (api *API) GetWebAnalyticsSite(ctx context.Context, rc *ResourceContainer, params GetWebAnalyticsSiteParams) (*WebAnalyticsSite, error) {
+	if rc.Level != AccountRouteLevel {
+		return nil, ErrRequiredAccountLevelResourceContainer
+	}
+	if params.SiteTag == "" {
+		return nil, ErrMissingWebAnalyticsSiteTag
+	}
+	uri := fmt.Sprintf("/accounts/%s/rum/site_info/%s", rc.Identifier, params.SiteTag)
+	res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil)
+	if err != nil {
+		return nil, err
+	}
+	var r WebAnalyticsSiteResponse
+	err = json.Unmarshal(res, &r)
+	if err != nil {
+		return nil, fmt.Errorf("%s: %w", errUnmarshalError, err)
+	}
+	return &r.Result, nil
+}
+
+type UpdateWebAnalyticsSiteParams struct {
+	SiteTag string `json:"-"`
+	// Host is the host to measure traffic for.
+	Host string `json:"host,omitempty"`
+	// ZoneTag is the zone tag to measure traffic for.
+	ZoneTag string `json:"zone_tag,omitempty"`
+	// AutoInstall defines whether Cloudflare will inject the JS snippet automatically for orange-clouded sites.
+	AutoInstall *bool `json:"auto_install"`
+}
+
+// UpdateWebAnalyticsSite updates an existing Web Analytics Site for an Account.
+//
+// API reference: https://api.cloudflare.com/#web-analytics-update-site
+func (api *API) UpdateWebAnalyticsSite(ctx context.Context, rc *ResourceContainer, params UpdateWebAnalyticsSiteParams) (*WebAnalyticsSite, error) {
+	if rc.Level != AccountRouteLevel {
+		return nil, ErrRequiredAccountLevelResourceContainer
+	}
+	if params.SiteTag == "" {
+		return nil, ErrMissingWebAnalyticsSiteTag
+	}
+	if params.AutoInstall == nil {
+		// default auto_install to true for orange-clouded zones (zone_tag is specified)
+		params.AutoInstall = BoolPtr(params.ZoneTag != "")
+	}
+	uri := fmt.Sprintf("/accounts/%s/rum/site_info/%s", rc.Identifier, params.SiteTag)
+	res, err := api.makeRequestContext(ctx, http.MethodPut, uri, params)
+	if err != nil {
+		return nil, err
+	}
+	var r WebAnalyticsSiteResponse
+	err = json.Unmarshal(res, &r)
+	if err != nil {
+		return nil, fmt.Errorf("%s: %w", errUnmarshalError, err)
+	}
+	return &r.Result, nil
+}
+
+type DeleteWebAnalyticsSiteParams struct {
+	SiteTag string
+}
+
+// DeleteWebAnalyticsSite deletes an existing Web Analytics Site for an Account.
+//
+// API reference: https://api.cloudflare.com/#web-analytics-delete-site
+func (api *API) DeleteWebAnalyticsSite(ctx context.Context, rc *ResourceContainer, params DeleteWebAnalyticsSiteParams) (*string, error) {
+	if rc.Level != AccountRouteLevel {
+		return nil, ErrRequiredAccountLevelResourceContainer
+	}
+	if params.SiteTag == "" {
+		return nil, ErrMissingWebAnalyticsSiteTag
+	}
+	uri := fmt.Sprintf("/accounts/%s/rum/site_info/%s", rc.Identifier, params.SiteTag)
+	res, err := api.makeRequestContext(ctx, http.MethodDelete, uri, nil)
+	if err != nil {
+		return nil, err
+	}
+	var r WebAnalyticsSiteResponse
+	err = json.Unmarshal(res, &r)
+	if err != nil {
+		return nil, fmt.Errorf("%s: %w", errUnmarshalError, err)
+	}
+	return &r.Result.SiteTag, nil
+}
+
+type CreateWebAnalyticsRuleParams struct {
+	RulesetID string
+	Rule      CreateWebAnalyticsRule
+}
+
+// CreateWebAnalyticsRule creates a new Web Analytics Rule in a Web Analytics ruleset.
+//
+// API reference: https://api.cloudflare.com/#web-analytics-create-rule
+func (api *API) CreateWebAnalyticsRule(ctx context.Context, rc *ResourceContainer, params CreateWebAnalyticsRuleParams) (*WebAnalyticsRule, error) {
+	if rc.Level != AccountRouteLevel {
+		return nil, ErrRequiredAccountLevelResourceContainer
+	}
+	if params.RulesetID == "" {
+		return nil, ErrMissingWebAnalyticsRulesetID
+	}
+	uri := fmt.Sprintf("/accounts/%s/rum/v2/%s/rule", rc.Identifier, params.RulesetID)
+	res, err := api.makeRequestContext(ctx, http.MethodPost, uri, params.Rule)
+	if err != nil {
+		return nil, err
+	}
+	var r WebAnalyticsRuleResponse
+	err = json.Unmarshal(res, &r)
+	if err != nil {
+		return nil, fmt.Errorf("%s: %w", errUnmarshalError, err)
+	}
+	return &r.Result, nil
+}
+
+type ListWebAnalyticsRulesParams struct {
+	RulesetID string
+}
+
+// ListWebAnalyticsRules fetches all Web Analytics Rules in a Web Analytics ruleset.
+//
+// API reference: https://api.cloudflare.com/#web-analytics-list-rules
+func (api *API) ListWebAnalyticsRules(ctx context.Context, rc *ResourceContainer, params ListWebAnalyticsRulesParams) (*WebAnalyticsRulesetRules, error) {
+	if rc.Level != AccountRouteLevel {
+		return nil, ErrRequiredAccountLevelResourceContainer
+	}
+	if params.RulesetID == "" {
+		return nil, ErrMissingWebAnalyticsRulesetID
+	}
+	uri := fmt.Sprintf("/accounts/%s/rum/v2/%s/rules", rc.Identifier, params.RulesetID)
+	res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil)
+	if err != nil {
+		return nil, err
+	}
+	var r WebAnalyticsRulesResponse
+	err = json.Unmarshal(res, &r)
+	if err != nil {
+		return nil, fmt.Errorf("%s: %w", errUnmarshalError, err)
+	}
+	return &r.Result, nil
+}
+
+type DeleteWebAnalyticsRuleParams struct {
+	RulesetID string
+	RuleID    string
+}
+
+// DeleteWebAnalyticsRule deletes an existing Web Analytics Rule from a Web Analytics ruleset.
+//
+// API reference: https://api.cloudflare.com/#web-analytics-delete-rule
+func (api *API) DeleteWebAnalyticsRule(ctx context.Context, rc *ResourceContainer, params DeleteWebAnalyticsRuleParams) (*string, error) {
+	if rc.Level != AccountRouteLevel {
+		return nil, ErrRequiredAccountLevelResourceContainer
+	}
+	if params.RulesetID == "" {
+		return nil, ErrMissingWebAnalyticsRulesetID
+	}
+	if params.RuleID == "" {
+		return nil, ErrMissingWebAnalyticsRuleID
+	}
+	uri := fmt.Sprintf("/accounts/%s/rum/v2/%s/rule/%s", rc.Identifier, params.RulesetID, params.RuleID)
+	res, err := api.makeRequestContext(ctx, http.MethodDelete, uri, nil)
+	if err != nil {
+		return nil, err
+	}
+	var r WebAnalyticsIDResponse
+	err = json.Unmarshal(res, &r)
+	if err != nil {
+		return nil, fmt.Errorf("%s: %w", errUnmarshalError, err)
+	}
+	return &r.Result.ID, nil
+}
+
+type UpdateWebAnalyticsRuleParams struct {
+	RulesetID string
+	RuleID    string
+	Rule      CreateWebAnalyticsRule
+}
+
+// UpdateWebAnalyticsRule updates a Web Analytics Rule in a Web Analytics ruleset.
+//
+// API reference: https://api.cloudflare.com/#web-analytics-update-rule
+func (api *API) UpdateWebAnalyticsRule(ctx context.Context, rc *ResourceContainer, params UpdateWebAnalyticsRuleParams) (*WebAnalyticsRule, error) {
+	if rc.Level != AccountRouteLevel {
+		return nil, ErrRequiredAccountLevelResourceContainer
+	}
+	if params.RulesetID == "" {
+		return nil, ErrMissingWebAnalyticsRulesetID
+	}
+	if params.RuleID == "" {
+		return nil, ErrMissingWebAnalyticsRuleID
+	}
+	uri := fmt.Sprintf("/accounts/%s/rum/v2/%s/rule/%s", rc.Identifier, params.RulesetID, params.RuleID)
+	res, err := api.makeRequestContext(ctx, http.MethodPut, uri, params.Rule)
+	if err != nil {
+		return nil, err
+	}
+	var r WebAnalyticsRuleResponse
+	err = json.Unmarshal(res, &r)
+	if err != nil {
+		return nil, fmt.Errorf("%s: %w", errUnmarshalError, err)
+	}
+	return &r.Result, nil
+}


Test patch:
diff --git a/web_analytics_test.go b/web_analytics_test.go
new file mode 100644
index 000000000..cbacbb2de
--- /dev/null
+++ b/web_analytics_test.go
@@ -0,0 +1,385 @@
+package cloudflare
+
+import (
+	"context"
+	"fmt"
+	"io"
+	"net/http"
+	"strings"
+	"testing"
+	"time"
+
+	"github.com/stretchr/testify/assert"
+)
+
+var siteTag = "46c32e0ea0e85e90aa1a6df4596b831e"
+var siteToken = "75300e6c2c5648d983fcef2a6c03d14e"
+var rulesetID = "2e8804e9-674f-4652-94a4-1c664d0d6764"
+var ruleID = "3caf59c9-eda3-4f99-a4a3-ee5fc2358a78"
+
+// var snippetFormat = `\u003c!-- Cloudflare Web Analytics --\u003e\u003cscript defer src='https://static.cloudflareinsights.com/beacon.min.js' data-cf-beacon='{\"token\": \"%s\"}'\u003e\u003c/script\u003e\u003c!-- End Cloudflare Web Analytics --\u003e`.
+var snippetFormat = `%s`
+var createdTimestamp = time.Now().UTC()
+var siteJSON = fmt.Sprintf(`
+{
+  "site_tag": "%s",
+  "site_token": "%s",
+  "created": "%s",
+  "snippet": "%s",
+  "auto_install": true,
+  "ruleset": {
+    "zone_tag": "%s",
+    "zone_name": "example.com",
+    "enabled": true,
+    "id": "%s"
+  },
+  "rules": [
+    {
+      "host": "example.com",
+      "paths": [
+        "*"
+      ],
+      "inclusive": true,
+      "created": "%s",
+      "is_paused": false,
+      "priority": 1000,
+      "id": "%s"
+    }
+  ]
+}
+`, siteTag, siteToken, createdTimestamp.Format(time.RFC3339Nano), fmt.Sprintf(snippetFormat, siteToken), testZoneID, rulesetID, createdTimestamp.Format(time.RFC3339Nano), ruleID)
+
+var rulesetJSON = fmt.Sprintf(`
+{
+  "id": "%s",
+  "zone_tag": "%s",
+  "zone_name": "%s",
+  "enabled": true
+}
+`, rulesetID, testZoneID, "example.com")
+
+var ruleJSON = fmt.Sprintf(`
+{
+  "id": "%s",
+  "host": "example.com",
+  "paths": [
+    "*"
+  ],
+  "inclusive": true,
+  "created": "%s",
+  "is_paused": false,
+  "priority": 1000
+}
+`, ruleID, createdTimestamp.Format(time.RFC3339Nano))
+
+var site = WebAnalyticsSite{
+	SiteTag:     siteTag,
+	SiteToken:   siteToken,
+	AutoInstall: true,
+	Snippet:     fmt.Sprintf(snippetFormat, siteToken),
+	Ruleset:     ruleset,
+	Rules: []WebAnalyticsRule{
+		rule,
+	},
+	Created: TimePtr(createdTimestamp.UTC()),
+}
+
+var ruleset = WebAnalyticsRuleset{
+	ID:       rulesetID,
+	ZoneTag:  testZoneID,
+	ZoneName: "example.com",
+	Enabled:  true,
+}
+
+var rule = WebAnalyticsRule{
+	ID:   ruleID,
+	Host: "example.com",
+	Paths: []string{
+		"*",
+	},
+	Inclusive: true,
+	Created:   TimePtr(createdTimestamp.UTC()),
+	IsPaused:  false,
+	Priority:  1000,
+}
+
+func TestListWebAnalyticsSites(t *testing.T) {
+	setup()
+	defer teardown()
+
+	handler := func(w http.ResponseWriter, r *http.Request) {
+		assert.Equal(t, http.MethodGet, r.Method, "Expected method 'GET', got %s", r.Method)
+		assert.Equal(t, "1", r.URL.Query().Get("page"))
+		assert.Equal(t, "10", r.URL.Query().Get("per_page"))
+		assert.Equal(t, "host", r.URL.Query().Get("order_by"))
+		w.Header().Set("content-type", "application/json")
+		fmt.Fprintf(w, `{
+			  "success": true,
+			  "errors": [],
+			  "messages": [],
+			  "result": [
+			    %s
+			  ],
+              "result_info": {
+                "page": 1,
+                "per_page": 10,
+                "count": 1,
+                "total_count": 1,
+                "total_pages": 1
+              }
+			}
+		`, siteJSON)
+	}
+	mux.HandleFunc("/accounts/"+testAccountID+"/rum/site_info/list", handler)
+	want := []WebAnalyticsSite{site}
+	actual, resultInfo, err := client.ListWebAnalyticsSites(context.Background(), AccountIdentifier(testAccountID), ListWebAnalyticsSitesParams{
+		ResultInfo: ResultInfo{
+			Page:    0,
+			PerPage: 0,
+		},
+		OrderBy: "host",
+	})
+	if assert.NoError(t, err) {
+		assert.Equal(t, want, actual)
+		assert.Equal(t, &ResultInfo{
+			Page:       1,
+			PerPage:    10,
+			TotalPages: 1,
+			Count:      1,
+			Total:      1,
+		}, resultInfo)
+		assert.Len(t, actual, 1)
+	}
+}
+
+func TestGetWebAnalyticsSite(t *testing.T) {
+	setup()
+	defer teardown()
+
+	handler := func(w http.ResponseWriter, r *http.Request) {
+		assert.Equal(t, http.MethodGet, r.Method, "Expected method 'GET', got %s", r.Method)
+		w.Header().Set("content-type", "application/json")
+		fmt.Fprintf(w, `{
+			  "success": true,
+			  "errors": [],
+			  "messages": [],
+			  "result": %s
+			}
+		`, siteJSON)
+	}
+	mux.HandleFunc("/accounts/"+testAccountID+"/rum/site_info/"+siteTag, handler)
+	want := site
+	actual, err := client.GetWebAnalyticsSite(context.Background(), AccountIdentifier(testAccountID), GetWebAnalyticsSiteParams{
+		SiteTag: siteTag,
+	})
+	if assert.NoError(t, err) {
+		assert.Equal(t, &want, actual)
+	}
+}
+
+func TestCreateWebAnalyticsSite(t *testing.T) {
+	setup()
+	defer teardown()
+
+	handler := func(w http.ResponseWriter, r *http.Request) {
+		assert.Equal(t, http.MethodPost, r.Method, "Expected method 'POST', got %s", r.Method)
+		body, err := io.ReadAll(r.Body)
+		assert.NoError(t, err)
+		assert.True(t, strings.Contains(string(body), `"auto_install":true`))
+		w.Header().Set("content-type", "application/json")
+		fmt.Fprintf(w, `{
+			  "success": true,
+			  "errors": [],
+			  "messages": [],
+			  "result": %s
+			}
+		`, siteJSON)
+	}
+	mux.HandleFunc("/accounts/"+testAccountID+"/rum/site_info", handler)
+	want := site
+	actual, err := client.CreateWebAnalyticsSite(context.Background(), AccountIdentifier(testAccountID), CreateWebAnalyticsSiteParams{
+		ZoneTag: testZoneID,
+		//AutoInstall: BoolPtr(true),  // should default to true
+	})
+	if assert.NoError(t, err) {
+		assert.Equal(t, &want, actual)
+	}
+}
+
+func TestUpdateWebAnalyticsSite(t *testing.T) {
+	setup()
+	defer teardown()
+
+	handler := func(w http.ResponseWriter, r *http.Request) {
+		assert.Equal(t, http.MethodPut, r.Method, "Expected method 'PUT', got %s", r.Method)
+		w.Header().Set("content-type", "application/json")
+		fmt.Fprintf(w, `{
+			  "success": true,
+			  "errors": [],
+			  "messages": [],
+			  "result": %s
+			}
+		`, siteJSON)
+	}
+	mux.HandleFunc("/accounts/"+testAccountID+"/rum/site_info/"+siteTag, handler)
+	want := site
+	actual, err := client.UpdateWebAnalyticsSite(context.Background(), AccountIdentifier(testAccountID), UpdateWebAnalyticsSiteParams{
+		SiteTag:     site.SiteTag,
+		Host:        "example.com",
+		AutoInstall: BoolPtr(true),
+	})
+	if assert.NoError(t, err) {
+		assert.Equal(t, &want, actual)
+	}
+}
+
+func TestDeleteWebAnalyticsSite(t *testing.T) {
+	setup()
+	defer teardown()
+
+	handler := func(w http.ResponseWriter, r *http.Request) {
+		assert.Equal(t, http.MethodDelete, r.Method, "Expected method 'GET', got %s", r.Method)
+		w.Header().Set("content-type", "application/json")
+		fmt.Fprintf(w, `{
+			  "success": true,
+			  "errors": [],
+			  "messages": [],
+			  "result": {
+                "site_tag": "%s"
+              }
+			}
+		`, siteTag)
+	}
+	mux.HandleFunc("/accounts/"+testAccountID+"/rum/site_info/"+siteTag, handler)
+	want := siteTag
+	actual, err := client.DeleteWebAnalyticsSite(context.Background(), AccountIdentifier(testAccountID), DeleteWebAnalyticsSiteParams{
+		SiteTag: siteTag,
+	})
+	if assert.NoError(t, err) {
+		assert.Equal(t, &want, actual)
+	}
+}
+
+func TestListWebAnalyticsRules(t *testing.T) {
+	setup()
+	defer teardown()
+
+	handler := func(w http.ResponseWriter, r *http.Request) {
+		assert.Equal(t, http.MethodGet, r.Method, "Expected method 'GET', got %s", r.Method)
+		w.Header().Set("content-type", "application/json")
+		fmt.Fprintf(w, `{
+			  "success": true,
+			  "errors": [],
+			  "messages": [],
+			  "result": {
+                "ruleset": %s,
+                "rules": [
+                  %s
+                ]
+              }
+			}
+		`, rulesetJSON, ruleJSON)
+	}
+	mux.HandleFunc("/accounts/"+testAccountID+"/rum/v2/"+rulesetID+"/rules", handler)
+	want := WebAnalyticsRulesetRules{
+		Ruleset: ruleset,
+		Rules:   []WebAnalyticsRule{rule},
+	}
+	actual, err := client.ListWebAnalyticsRules(context.Background(), AccountIdentifier(testAccountID), ListWebAnalyticsRulesParams{
+		RulesetID: rulesetID,
+	})
+	if assert.NoError(t, err) {
+		assert.Equal(t, &want, actual)
+	}
+}
+
+func TestCreateWebAnalyticsRule(t *testing.T) {
+	setup()
+	defer teardown()
+
+	handler := func(w http.ResponseWriter, r *http.Request) {
+		assert.Equal(t, http.MethodPost, r.Method, "Expected method 'POST', got %s", r.Method)
+		w.Header().Set("content-type", "application/json")
+		fmt.Fprintf(w, `{
+			  "success": true,
+			  "errors": [],
+			  "messages": [],
+			  "result": %s
+			}
+		`, ruleJSON)
+	}
+	mux.HandleFunc("/accounts/"+testAccountID+"/rum/v2/"+rulesetID+"/rule", handler)
+	want := rule
+	actual, err := client.CreateWebAnalyticsRule(context.Background(), AccountIdentifier(testAccountID), CreateWebAnalyticsRuleParams{
+		RulesetID: rulesetID,
+		Rule: CreateWebAnalyticsRule{
+			Host:      "example.com",
+			Paths:     []string{"*"},
+			Inclusive: true,
+			IsPaused:  false,
+		},
+	})
+	if assert.NoError(t, err) {
+		assert.Equal(t, &want, actual)
+	}
+}
+
+func TestUpdateWebAnalyticsRule(t *testing.T) {
+	setup()
+	defer teardown()
+
+	handler := func(w http.ResponseWriter, r *http.Request) {
+		assert.Equal(t, http.MethodPut, r.Method, "Expected method 'PUT', got %s", r.Method)
+		w.Header().Set("content-type", "application/json")
+		fmt.Fprintf(w, `{
+			  "success": true,
+			  "errors": [],
+			  "messages": [],
+			  "result": %s
+			}
+		`, ruleJSON)
+	}
+	mux.HandleFunc("/accounts/"+testAccountID+"/rum/v2/"+rulesetID+"/rule/"+ruleID, handler)
+	want := rule
+	actual, err := client.UpdateWebAnalyticsRule(context.Background(), AccountIdentifier(testAccountID), UpdateWebAnalyticsRuleParams{
+		RulesetID: rulesetID,
+		RuleID:    ruleID,
+		Rule: CreateWebAnalyticsRule{
+			Host:      "example.com",
+			Paths:     []string{"*"},
+			Inclusive: true,
+			IsPaused:  false,
+		},
+	})
+	if assert.NoError(t, err) {
+		assert.Equal(t, &want, actual)
+	}
+}
+
+func TestDeleteWebAnalyticsRule(t *testing.T) {
+	setup()
+	defer teardown()
+
+	handler := func(w http.ResponseWriter, r *http.Request) {
+		assert.Equal(t, http.MethodDelete, r.Method, "Expected method 'DELETE', got %s", r.Method)
+		w.Header().Set("content-type", "application/json")
+		fmt.Fprintf(w, `{
+			  "success": true,
+			  "errors": [],
+			  "messages": [],
+			  "result": {
+                "id": "%s"
+              }
+			}
+		`, ruleID)
+	}
+	mux.HandleFunc("/accounts/"+testAccountID+"/rum/v2/"+rulesetID+"/rule/"+ruleID, handler)
+	want := ruleID
+	actual, err := client.DeleteWebAnalyticsRule(context.Background(), AccountIdentifier(testAccountID), DeleteWebAnalyticsRuleParams{
+		RulesetID: rulesetID,
+		RuleID:    ruleID,
+	})
+	if assert.NoError(t, err) {
+		assert.Equal(t, &want, actual)
+	}
+}