analytics_ops 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +47 -0
  3. data/CODE_OF_CONDUCT.md +10 -0
  4. data/CONTRIBUTING.md +67 -0
  5. data/LICENSE.txt +21 -0
  6. data/README.md +188 -0
  7. data/SECURITY.md +56 -0
  8. data/docs/api-support-matrix.md +50 -0
  9. data/docs/architecture.md +89 -0
  10. data/docs/authentication.md +86 -0
  11. data/docs/commands.md +112 -0
  12. data/docs/configuration-schema-v1.json +129 -0
  13. data/docs/configuration.md +167 -0
  14. data/docs/google-client-compatibility.md +59 -0
  15. data/docs/plan-format.md +89 -0
  16. data/docs/plan-schema-v1.json +224 -0
  17. data/docs/rails.md +73 -0
  18. data/docs/reports.md +124 -0
  19. data/docs/safety.md +93 -0
  20. data/docs/troubleshooting.md +103 -0
  21. data/exe/analytics-ops +6 -0
  22. data/lib/analytics_ops/applier.rb +52 -0
  23. data/lib/analytics_ops/canonical.rb +65 -0
  24. data/lib/analytics_ops/cli.rb +427 -0
  25. data/lib/analytics_ops/clients/admin.rb +446 -0
  26. data/lib/analytics_ops/clients/data.rb +314 -0
  27. data/lib/analytics_ops/configuration/document.rb +25 -0
  28. data/lib/analytics_ops/configuration/loader.rb +76 -0
  29. data/lib/analytics_ops/configuration/schema.rb +107 -0
  30. data/lib/analytics_ops/configuration/validator.rb +344 -0
  31. data/lib/analytics_ops/configuration.rb +19 -0
  32. data/lib/analytics_ops/desired_state.rb +40 -0
  33. data/lib/analytics_ops/errors.rb +31 -0
  34. data/lib/analytics_ops/plan.rb +551 -0
  35. data/lib/analytics_ops/planner.rb +233 -0
  36. data/lib/analytics_ops/rails/railtie.rb +65 -0
  37. data/lib/analytics_ops/rails.rb +5 -0
  38. data/lib/analytics_ops/redaction.rb +46 -0
  39. data/lib/analytics_ops/reports/catalog.rb +100 -0
  40. data/lib/analytics_ops/reports/definition.rb +226 -0
  41. data/lib/analytics_ops/reports/result.rb +90 -0
  42. data/lib/analytics_ops/reports.rb +13 -0
  43. data/lib/analytics_ops/resources.rb +79 -0
  44. data/lib/analytics_ops/snapshot.rb +45 -0
  45. data/lib/analytics_ops/version.rb +5 -0
  46. data/lib/analytics_ops/workspace.rb +212 -0
  47. data/lib/analytics_ops.rb +21 -0
  48. data/lib/generators/analytics_ops/install_generator.rb +23 -0
  49. data/lib/generators/analytics_ops/templates/analytics-ops +9 -0
  50. data/lib/generators/analytics_ops/templates/analytics_ops.yml +23 -0
  51. data/sig/analytics_ops.rbs +395 -0
  52. metadata +131 -0
@@ -0,0 +1,224 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://github.com/nelsonchaves/analytics_ops/blob/master/docs/plan-schema-v1.json",
4
+ "title": "Analytics Ops saved plan version 1",
5
+ "type": "object",
6
+ "additionalProperties": false,
7
+ "required": ["format_version", "profile", "property_id", "snapshot_fingerprint", "changes", "findings"],
8
+ "properties": {
9
+ "format_version": { "const": 1 },
10
+ "profile": { "type": "string", "pattern": "^[A-Za-z][A-Za-z0-9_]{0,63}$" },
11
+ "property_id": { "$ref": "#/$defs/id" },
12
+ "snapshot_fingerprint": { "type": "string", "pattern": "^sha256:[a-f0-9]{64}$" },
13
+ "changes": {
14
+ "type": "array",
15
+ "maxItems": 10000,
16
+ "items": { "$ref": "#/$defs/change" }
17
+ },
18
+ "findings": {
19
+ "type": "array",
20
+ "maxItems": 10000,
21
+ "items": { "$ref": "#/$defs/finding" }
22
+ }
23
+ },
24
+ "$defs": {
25
+ "id": { "type": "string", "pattern": "^[0-9]{1,50}$" },
26
+ "text": { "type": "string", "pattern": "^[^\\u0000-\\u001F\\u007F]{1,500}$" },
27
+ "resourceName": {
28
+ "type": "string",
29
+ "pattern": "^properties/[0-9]{1,50}/[A-Za-z]+(?:/[A-Za-z0-9_-]+)?$"
30
+ },
31
+ "change": {
32
+ "type": "object",
33
+ "additionalProperties": false,
34
+ "required": [
35
+ "resource_type", "resource_identity", "operation", "api_maturity",
36
+ "before", "after", "reversible", "rollback"
37
+ ],
38
+ "properties": {
39
+ "resource_type": {
40
+ "enum": ["data_stream", "retention", "key_event", "custom_dimension", "custom_metric"]
41
+ },
42
+ "resource_identity": { "$ref": "#/$defs/text" },
43
+ "operation": { "enum": ["create", "update"] },
44
+ "api_maturity": { "enum": ["stable", "beta", "experimental"] },
45
+ "before": { "type": ["object", "null"] },
46
+ "after": { "type": "object" },
47
+ "reversible": { "type": "boolean" },
48
+ "rollback": { "$ref": "#/$defs/text" }
49
+ },
50
+ "oneOf": [
51
+ {
52
+ "properties": {
53
+ "resource_type": { "const": "data_stream" },
54
+ "resource_identity": { "pattern": "^stream:[0-9]{1,50}$" },
55
+ "operation": { "const": "update" },
56
+ "before": { "$ref": "#/$defs/dataStream" },
57
+ "after": { "$ref": "#/$defs/dataStream" }
58
+ }
59
+ },
60
+ {
61
+ "properties": {
62
+ "resource_type": { "const": "retention" },
63
+ "resource_identity": { "pattern": "^property:[0-9]{1,50}:retention$" },
64
+ "operation": { "const": "update" },
65
+ "before": { "$ref": "#/$defs/retention" },
66
+ "after": { "$ref": "#/$defs/retention" }
67
+ }
68
+ },
69
+ {
70
+ "properties": {
71
+ "resource_type": { "const": "key_event" },
72
+ "resource_identity": { "pattern": "^event:[A-Za-z][A-Za-z0-9_]{0,39}$" },
73
+ "operation": { "const": "create" },
74
+ "before": { "type": "null" },
75
+ "after": { "$ref": "#/$defs/keyEvent" }
76
+ }
77
+ },
78
+ {
79
+ "properties": {
80
+ "resource_type": { "const": "custom_dimension" },
81
+ "resource_identity": { "pattern": "^(?:event|user|item):[A-Za-z][A-Za-z0-9_]{0,39}$" },
82
+ "operation": { "const": "create" },
83
+ "before": { "type": "null" },
84
+ "after": { "$ref": "#/$defs/customDimensionCreate" }
85
+ }
86
+ },
87
+ {
88
+ "properties": {
89
+ "resource_type": { "const": "custom_dimension" },
90
+ "resource_identity": { "pattern": "^(?:event|user|item):[A-Za-z][A-Za-z0-9_]{0,39}$" },
91
+ "operation": { "const": "update" },
92
+ "before": { "$ref": "#/$defs/customDimensionUpdate" },
93
+ "after": { "$ref": "#/$defs/customDimensionUpdate" }
94
+ }
95
+ },
96
+ {
97
+ "properties": {
98
+ "resource_type": { "const": "custom_metric" },
99
+ "resource_identity": { "pattern": "^[A-Za-z][A-Za-z0-9_]{0,39}$" },
100
+ "operation": { "const": "create" },
101
+ "before": { "type": "null" },
102
+ "after": { "$ref": "#/$defs/customMetricCreate" }
103
+ }
104
+ },
105
+ {
106
+ "properties": {
107
+ "resource_type": { "const": "custom_metric" },
108
+ "resource_identity": { "pattern": "^[A-Za-z][A-Za-z0-9_]{0,39}$" },
109
+ "operation": { "const": "update" },
110
+ "before": { "$ref": "#/$defs/customMetricUpdate" },
111
+ "after": { "$ref": "#/$defs/customMetricUpdate" }
112
+ }
113
+ }
114
+ ]
115
+ },
116
+ "finding": {
117
+ "type": "object",
118
+ "additionalProperties": false,
119
+ "required": ["severity", "code", "resource_identity", "message"],
120
+ "properties": {
121
+ "severity": { "enum": ["drift", "manual", "experimental", "warning"] },
122
+ "code": { "type": "string", "pattern": "^[A-Za-z][A-Za-z0-9_]{0,63}$" },
123
+ "resource_identity": { "$ref": "#/$defs/text" },
124
+ "message": { "type": "string", "pattern": "^[^\\u0000-\\u001F\\u007F]{1,1000}$" }
125
+ }
126
+ },
127
+ "dataStream": {
128
+ "type": "object",
129
+ "additionalProperties": false,
130
+ "required": ["id", "name", "display_name", "type", "default_uri", "measurement_id"],
131
+ "properties": {
132
+ "id": { "$ref": "#/$defs/id" },
133
+ "name": { "$ref": "#/$defs/resourceName" },
134
+ "display_name": { "type": "string", "maxLength": 100 },
135
+ "type": { "enum": ["web", "android", "ios", "unspecified"] },
136
+ "default_uri": { "type": ["string", "null"], "format": "uri" },
137
+ "measurement_id": { "type": ["string", "null"], "maxLength": 64 }
138
+ }
139
+ },
140
+ "retention": {
141
+ "type": "object",
142
+ "additionalProperties": false,
143
+ "required": ["name", "event_data", "user_data", "reset_on_new_activity"],
144
+ "properties": {
145
+ "name": { "$ref": "#/$defs/resourceName" },
146
+ "event_data": { "$ref": "#/$defs/retentionValue" },
147
+ "user_data": { "$ref": "#/$defs/retentionValue" },
148
+ "reset_on_new_activity": { "type": "boolean" }
149
+ }
150
+ },
151
+ "retentionValue": {
152
+ "enum": ["2_months", "14_months", "26_months", "38_months", "50_months"]
153
+ },
154
+ "keyEvent": {
155
+ "type": "object",
156
+ "additionalProperties": false,
157
+ "required": ["event_name", "counting_method"],
158
+ "properties": {
159
+ "event_name": { "type": "string", "pattern": "^[A-Za-z][A-Za-z0-9_]{0,39}$" },
160
+ "counting_method": { "enum": ["once_per_event", "once_per_session"] }
161
+ }
162
+ },
163
+ "customDimensionCreate": {
164
+ "type": "object",
165
+ "additionalProperties": false,
166
+ "required": ["parameter_name", "display_name", "description", "scope", "disallow_ads_personalization"],
167
+ "properties": {
168
+ "parameter_name": { "type": "string", "pattern": "^[A-Za-z][A-Za-z0-9_]{0,39}$" },
169
+ "display_name": { "type": "string", "minLength": 1, "maxLength": 82 },
170
+ "description": { "type": "string", "maxLength": 150 },
171
+ "scope": { "enum": ["event", "user", "item"] },
172
+ "disallow_ads_personalization": { "type": "boolean" }
173
+ }
174
+ },
175
+ "customDimensionUpdate": {
176
+ "type": "object",
177
+ "additionalProperties": false,
178
+ "required": ["name", "parameter_name", "display_name", "description", "scope", "disallow_ads_personalization"],
179
+ "properties": {
180
+ "name": { "$ref": "#/$defs/resourceName" },
181
+ "parameter_name": { "type": "string", "pattern": "^[A-Za-z][A-Za-z0-9_]{0,39}$" },
182
+ "display_name": { "type": "string", "minLength": 1, "maxLength": 82 },
183
+ "description": { "type": "string", "maxLength": 150 },
184
+ "scope": { "enum": ["event", "user", "item"] },
185
+ "disallow_ads_personalization": { "type": "boolean" }
186
+ }
187
+ },
188
+ "customMetricCreate": {
189
+ "type": "object",
190
+ "additionalProperties": false,
191
+ "required": ["parameter_name", "display_name", "description", "scope", "measurement_unit"],
192
+ "properties": {
193
+ "parameter_name": { "type": "string", "pattern": "^[A-Za-z][A-Za-z0-9_]{0,39}$" },
194
+ "display_name": { "type": "string", "minLength": 1, "maxLength": 82 },
195
+ "description": { "type": "string", "maxLength": 150 },
196
+ "scope": { "const": "event" },
197
+ "measurement_unit": {
198
+ "enum": [
199
+ "standard", "currency", "feet", "meters", "kilometers", "miles",
200
+ "milliseconds", "seconds", "minutes", "hours"
201
+ ]
202
+ }
203
+ }
204
+ },
205
+ "customMetricUpdate": {
206
+ "type": "object",
207
+ "additionalProperties": false,
208
+ "required": ["name", "parameter_name", "display_name", "description", "scope", "measurement_unit"],
209
+ "properties": {
210
+ "name": { "$ref": "#/$defs/resourceName" },
211
+ "parameter_name": { "type": "string", "pattern": "^[A-Za-z][A-Za-z0-9_]{0,39}$" },
212
+ "display_name": { "type": "string", "minLength": 1, "maxLength": 82 },
213
+ "description": { "type": "string", "maxLength": 150 },
214
+ "scope": { "const": "event" },
215
+ "measurement_unit": {
216
+ "enum": [
217
+ "standard", "currency", "feet", "meters", "kilometers", "miles",
218
+ "milliseconds", "seconds", "minutes", "hours"
219
+ ]
220
+ }
221
+ }
222
+ }
223
+ }
224
+ }
data/docs/rails.md ADDED
@@ -0,0 +1,73 @@
1
+ # Rails integration
2
+
3
+ Rails support is optional and lives behind:
4
+
5
+ ```ruby
6
+ # Gemfile
7
+ gem "analytics_ops", require: "analytics_ops/rails", group: :development
8
+ ```
9
+
10
+ It supports Rails 7.2, 8.0, and 8.1. The core gem does not depend on Rails or
11
+ Active Support.
12
+
13
+ ## Install
14
+
15
+ ```bash
16
+ bundle install
17
+ bin/rails generate analytics_ops:install
18
+ ```
19
+
20
+ The generator creates only:
21
+
22
+ - `config/analytics_ops.yml`
23
+ - `bin/analytics-ops` with executable mode
24
+
25
+ The generated file has a `development` profile and fake-safe placeholders.
26
+ Add a profile matching the Rails environment you intend to operate, or set
27
+ `ANALYTICS_OPS_PROFILE`.
28
+
29
+ ## Rake tasks
30
+
31
+ ```bash
32
+ bin/rake analytics:doctor
33
+ bin/rake analytics:audit
34
+ bin/rake analytics:plan
35
+ bin/rake analytics:verify
36
+ bin/rake 'analytics:report[traffic_acquisition]'
37
+ ```
38
+
39
+ `analytics:plan` writes `tmp/analytics_ops-plan.json`. Override task inputs
40
+ without changing application code:
41
+
42
+ ```bash
43
+ ANALYTICS_OPS_PROFILE=production \
44
+ ANALYTICS_OPS_CONFIG=config/analytics_ops.yml \
45
+ ANALYTICS_OPS_PLAN=tmp/production-ga4-plan.json \
46
+ bin/rake analytics:plan
47
+ ```
48
+
49
+ There is intentionally no `analytics:apply` Rake task. Apply a reviewed file
50
+ with the explicit CLI:
51
+
52
+ ```bash
53
+ bin/analytics-ops apply tmp/production-ga4-plan.json
54
+ ```
55
+
56
+ ## Runtime boundary
57
+
58
+ The integration is a Railtie, not an Engine. It has no:
59
+
60
+ - models or database
61
+ - migrations
62
+ - routes or controllers
63
+ - views or helpers
64
+ - assets or JavaScript
65
+ - browser tag injection
66
+
67
+ Requiring `analytics_ops/rails`, booting Rails, and registering Rake tasks do
68
+ not construct Google clients or make network requests. A network call begins
69
+ only when an operator invokes a task or CLI operation.
70
+
71
+ Do not place production mutation credentials in a Rails web container.
72
+ Scheduled audits may use a dedicated read-only identity. Interactive apply
73
+ belongs on an administrator workstation or in a protected release workflow.
data/docs/reports.md ADDED
@@ -0,0 +1,124 @@
1
+ # Reports
2
+
3
+ Reports are read-only Data API calls. Analytics Ops validates an immutable
4
+ definition, sends an official-client request, and returns a gem-owned
5
+ `AnalyticsOps::Reports::Result`.
6
+
7
+ ## Built-in recipes
8
+
9
+ All standard recipes cover the previous 28 complete days
10
+ (`28daysAgo` through `yesterday`).
11
+
12
+ | Name | What it answers | Required custom definitions |
13
+ | --- | --- | --- |
14
+ | `traffic_acquisition` | Sessions, users, and key events by channel/source/medium | None |
15
+ | `landing_pages` | Sessions, users, and key events by landing page | None |
16
+ | `calculator_completions` | Completed calculations by calculator | `customEvent:calculator_slug` |
17
+ | `shares_and_prints` | Share and print events by calculator | `customEvent:calculator_slug` |
18
+ | `related_calculator_navigation` | Related-calculator clicks by source and destination | `customEvent:calculator_slug`, `customEvent:related_calculator_slug` |
19
+ | `commercial_outbound_clicks` | Commercial outbound clicks by calculator and destination | `customEvent:calculator_slug`, `customEvent:outbound_destination` |
20
+ | `realtime_events` | Event count and active users by event name | None |
21
+
22
+ The event names used by the calculator recipes are
23
+ `calculation_completed`, `result_shared`, `result_printed`,
24
+ `related_calculator_clicked`, and `commercial_outbound_clicked`.
25
+ If your site uses different names, create a custom Ruby definition.
26
+
27
+ ## CLI
28
+
29
+ ```bash
30
+ analytics-ops report traffic_acquisition
31
+ analytics-ops report landing_pages --format json
32
+ analytics-ops report calculator_completions --format csv
33
+ analytics-ops realtime
34
+ ```
35
+
36
+ CSV is accepted only for `report` and `realtime`. The CSV renderer protects
37
+ cells that start with `=`, `+`, `-`, or `@` from spreadsheet formula
38
+ execution.
39
+
40
+ ## Ruby
41
+
42
+ ```ruby
43
+ workspace = AnalyticsOps::Workspace.load(
44
+ "config/analytics_ops.yml",
45
+ profile: "production"
46
+ )
47
+
48
+ result = workspace.report("traffic_acquisition")
49
+ puts result.headers
50
+ puts result.rows
51
+ puts result.metadata
52
+ ```
53
+
54
+ Custom immutable definition:
55
+
56
+ ```ruby
57
+ definition = AnalyticsOps::Reports::Definition.new(
58
+ name: "recent_events",
59
+ kind: "standard",
60
+ dimensions: ["eventName"],
61
+ metrics: ["eventCount"],
62
+ date_ranges: [
63
+ { "start_date" => "7daysAgo", "end_date" => "yesterday" }
64
+ ],
65
+ dimension_filter: {
66
+ "field" => "eventName",
67
+ "match_type" => "begins_with",
68
+ "value" => "calculator_",
69
+ "case_sensitive" => false
70
+ },
71
+ order_bys: [
72
+ { "metric" => "eventCount", "desc" => true }
73
+ ],
74
+ limit: 100
75
+ )
76
+
77
+ result = workspace.report(definition)
78
+ ```
79
+
80
+ Definitions reject unknown fields, malformed names, duplicate dimensions or
81
+ metrics, invalid date order, filters that reference unselected fields,
82
+ non-finite numeric filters, invalid ordering, and unbounded limits. Realtime
83
+ definitions cannot use date ranges or offsets.
84
+
85
+ ## Result shape
86
+
87
+ `result.to_h` contains:
88
+
89
+ - `name` and `kind`
90
+ - separate dimension and metric headers
91
+ - rows keyed by header name
92
+ - Google's total `row_count`
93
+ - response metadata when present, including thresholding, sampling, time zone,
94
+ currency, empty reason, and property quota
95
+
96
+ Metric values remain strings, matching the Data API wire contract. Generated
97
+ Google types never escape the adapter.
98
+
99
+ ## GA reporting limits
100
+
101
+ A GA4 report is not an exact count of all site activity:
102
+
103
+ - It covers only traffic collected under your site's consent and tagging
104
+ behavior. This gem does not inject tags or manage consent.
105
+ - Google may apply data thresholds, sampling, cardinality limits, modeled data,
106
+ attribution rules, or an `(other)` row.
107
+ - Standard reports can lag behind collection; realtime is short-lived and is
108
+ useful for verification, not accounting.
109
+ - Data API results can differ from the Analytics UI because the selected
110
+ dimensions, metrics, filters, identity, and attribution semantics differ.
111
+ - Quotas belong to Google and can change. Analytics Ops returns quota metadata
112
+ when Google supplies it.
113
+ - Custom dimensions must already be registered and populated. Their Data API
114
+ names use `customEvent:parameter_name`.
115
+
116
+ Do not treat report output as billing, audit, or consent evidence. Avoid
117
+ personally identifiable or user-level dimensions, and review exported CSV/JSON
118
+ as potentially sensitive operational data.
119
+
120
+ Official references:
121
+
122
+ - [Data API reporting](https://developers.google.com/analytics/devguides/reporting/data/v1)
123
+ - [Reporting data expectations](https://developers.google.com/analytics/devguides/reporting/data/v1/data-expectations)
124
+ - [API dimensions and metrics](https://developers.google.com/analytics/devguides/reporting/data/v1/api-schema)
data/docs/safety.md ADDED
@@ -0,0 +1,93 @@
1
+ # Safety
2
+
3
+ Analytics Ops is read-only unless you explicitly apply a saved plan.
4
+
5
+ ## What cannot mutate
6
+
7
+ `doctor`, `discover`, `snapshot`, `audit`, `plan`, `verify`,
8
+ `report`, and `realtime` only read Google APIs. Loading the gem, parsing
9
+ YAML, and booting Rails make no network request.
10
+
11
+ ## What apply requires
12
+
13
+ An apply needs all of these:
14
+
15
+ 1. A saved version-1 JSON plan.
16
+ 2. A configuration profile matching the plan's profile and property ID.
17
+ 3. Explicit confirmation—typed `yes`, or both `--non-interactive --yes`.
18
+ 4. A fresh remote snapshot with the exact saved fingerprint.
19
+
20
+ The applier executes only the operations stored in the file. It never replans
21
+ during apply and never turns findings into changes.
22
+
23
+ ## Stale plans
24
+
25
+ Every plan fingerprints normalized managed remote state with SHA-256. If that
26
+ state changed after planning, apply exits 79 without performing any saved
27
+ operation:
28
+
29
+ ```text
30
+ StalePlanError: Remote state changed after this plan was generated; create a new plan
31
+ ```
32
+
33
+ Generate and review a new plan. Do not edit the fingerprint to force a stale
34
+ plan through.
35
+
36
+ ## Destructive operations
37
+
38
+ Ordinary plans contain only supported creates and updates. They never:
39
+
40
+ - delete an account, property, stream, key event, dimension, or metric
41
+ - archive a custom definition
42
+ - delete an unmanaged resource
43
+ - recreate an immutable conflict
44
+ - create a missing stream automatically
45
+
46
+ Configuration removal does not mean remote deletion. It simply stops managing
47
+ that declaration.
48
+
49
+ ## Partial apply and rollback
50
+
51
+ Apply stops on the first failed operation. Exit 80 includes:
52
+
53
+ - successfully applied changes
54
+ - the failed change and redacted error
55
+ - unattempted remaining changes
56
+
57
+ Analytics Ops does not attempt an automatic rollback because rollback itself
58
+ can fail or overwrite concurrent operator work. Each change includes a
59
+ human-readable rollback instruction. Reconcile the remote state, then generate
60
+ a fresh plan. Never reuse the old fingerprint.
61
+
62
+ Some create rollbacks require a deliberate manual delete or archive in Google
63
+ Analytics. Those destructive actions are outside the ordinary CLI.
64
+
65
+ ## Plan-file defenses
66
+
67
+ Saved plans are deterministic JSON, limited to 1 MiB, and written atomically
68
+ with mode 0600. Loading rejects:
69
+
70
+ - unknown or missing fields
71
+ - duplicate JSON keys
72
+ - incorrect scalar types
73
+ - invalid resource identities
74
+ - unknown resource payload fields
75
+ - unsupported operations
76
+ - no-op updates and immutable-field changes
77
+ - secret-shaped fields and control characters
78
+ - resource names for another property
79
+
80
+ Workspace and adapter checks provide additional cross-property protection.
81
+ The public schema is [plan-schema-v1.json](plan-schema-v1.json).
82
+
83
+ ## Credential and data boundaries
84
+
85
+ - Credentials never belong in YAML, plans, fixtures, logs, or command output.
86
+ - Report rows are never logged automatically.
87
+ - The gem has no telemetry and stores no report results.
88
+ - The gem does not inject browser analytics or manage consent.
89
+ - Production mutation credentials should not exist in a Rails web container.
90
+ - Real production IDs and exports do not belong in repository fixtures.
91
+
92
+ Use a read-only identity for routine audits and reports. Use a separately
93
+ protected edit identity only for a reviewed apply.
@@ -0,0 +1,103 @@
1
+ # Troubleshooting
2
+
3
+ Start with:
4
+
5
+ ```bash
6
+ analytics-ops doctor --format json
7
+ ```
8
+
9
+ The command is read-only and checks both Google APIs.
10
+
11
+ ## Configuration error
12
+
13
+ ```text
14
+ ConfigurationError: ... property_id must be a numeric identifier encoded as a string
15
+ ```
16
+
17
+ Quote numeric IDs:
18
+
19
+ ```yaml
20
+ property_id: "123456789"
21
+ stream_id: "987654321"
22
+ ```
23
+
24
+ Do not use account ID `100000001` or measurement ID `G-EXAMPLE1` in those
25
+ fields. Unknown keys, ERB, YAML aliases, missing environment variables, and
26
+ secret-shaped fields are intentionally rejected.
27
+
28
+ ## Authentication failure (exit 66)
29
+
30
+ Create ADC again and ensure both APIs are enabled:
31
+
32
+ ```bash
33
+ gcloud auth application-default login \
34
+ --scopes="https://www.googleapis.com/auth/cloud-platform,https://www.googleapis.com/auth/analytics.readonly"
35
+ ```
36
+
37
+ If `GOOGLE_APPLICATION_CREDENTIALS` is set, confirm that it points to the
38
+ intended local credential file. Never add that path or file to configuration
39
+ or source control.
40
+
41
+ ## Permission failure (exit 77)
42
+
43
+ The Google identity needs access inside the GA4 property. Cloud IAM alone is
44
+ not enough. Add the user or service-account identity to the property with a
45
+ read role; use an edit-capable role only for plan application.
46
+
47
+ Use `analytics-ops discover` to see accessible numeric property IDs.
48
+
49
+ ## API or remote failure (exit 69)
50
+
51
+ Confirm that the Google Analytics Admin API and Data API are enabled in the
52
+ credential's Cloud project. Check the configuration ID and the error's typed
53
+ message. Invalid dimensions, metrics, custom definitions, or property
54
+ restrictions can also produce this status.
55
+
56
+ ## Quota (exit 75) or timeout (exit 74)
57
+
58
+ Wait for Google quota to recover, reduce report frequency, or use a positive
59
+ `--timeout`. Do not blindly retry an apply after an uncertain response;
60
+ snapshot and replan so remote state is reconciled first.
61
+
62
+ ## Drift status 2
63
+
64
+ This is expected automation behavior, not a crash. `audit` and `verify`
65
+ return 2 when managed state differs or a drift finding remains.
66
+
67
+ ## Stale plan (exit 79)
68
+
69
+ Someone or something changed relevant remote state after the plan was created.
70
+ Generate a new plan, review it, and apply that new file. Never edit the saved
71
+ fingerprint.
72
+
73
+ ## Partial apply (exit 80)
74
+
75
+ Read the reconciliation output. It lists applied, failed, and unattempted
76
+ operations. Verify the property, perform any deliberate manual rollback, then
77
+ generate a new plan. Do not rerun the old plan.
78
+
79
+ ## A custom report fails
80
+
81
+ Confirm every requested dimension and metric is compatible and registered.
82
+ Event-scoped custom fields use names such as
83
+ `customEvent:calculator_slug`. Standard reports require a date range;
84
+ realtime reports reject date ranges and offsets.
85
+
86
+ Google thresholding, sampling, consent coverage, cardinality, and processing
87
+ delay can explain an empty or lower-than-expected result. Inspect
88
+ `result.metadata` in JSON output.
89
+
90
+ ## Rails task cannot find a profile
91
+
92
+ Rails tasks default to `Rails.env`. The generator creates a
93
+ `development` profile. Add the intended profile or override it:
94
+
95
+ ```bash
96
+ ANALYTICS_OPS_PROFILE=production bin/rake analytics:doctor
97
+ ```
98
+
99
+ ## Report a problem safely
100
+
101
+ Include the gem version, Ruby version, command name, exit status, and a minimal
102
+ configuration with obviously fake IDs. Do not include credentials, tokens,
103
+ authorization headers, real report rows, production plans, or private keys.
data/exe/analytics-ops ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "analytics_ops/cli"
5
+
6
+ exit AnalyticsOps::CLI.start(ARGV)
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Explicit saved-plan application.
4
+
5
+ module AnalyticsOps
6
+ # Executes only the operations contained in an approved, non-stale plan.
7
+ class Applier
8
+ # Immutable success or partial-reconciliation summary.
9
+ class Result < Resources::Value
10
+ fields :status, :applied, :failed, :remaining
11
+ end
12
+
13
+ def initialize(admin:)
14
+ @admin = admin
15
+ end
16
+
17
+ def call(plan, confirm: false)
18
+ raise ConfirmationRequiredError, "Applying a plan requires explicit confirmation" unless confirm
19
+
20
+ current = @admin.snapshot(plan.property_id)
21
+ unless current.fingerprint == plan.snapshot_fingerprint
22
+ raise StalePlanError, "Remote state changed after this plan was generated; create a new plan"
23
+ end
24
+
25
+ applied = []
26
+ plan.changes.each_with_index do |change, index|
27
+ @admin.apply_change(change, property_id: plan.property_id)
28
+ applied << change.to_h
29
+ rescue StandardError => error
30
+ result = Result.new(
31
+ status: "partial",
32
+ applied:,
33
+ failed: failure(change, error),
34
+ remaining: plan.changes.drop(index + 1).map(&:to_h)
35
+ )
36
+ raise PartialApplyError.new("Apply stopped after #{applied.length} successful changes", result:)
37
+ end
38
+
39
+ Result.new(status: "applied", applied:, failed: nil, remaining: [])
40
+ end
41
+
42
+ private
43
+
44
+ def failure(change, error)
45
+ {
46
+ "change" => change.to_h,
47
+ "error_type" => error.class.name,
48
+ "message" => Redaction.message(error.message).slice(0, 500)
49
+ }
50
+ end
51
+ end
52
+ end