analytics_ops 0.1.0 → 0.2.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 (56) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +76 -0
  3. data/CONTRIBUTING.md +2 -1
  4. data/README.md +232 -41
  5. data/SECURITY.md +8 -8
  6. data/docs/api-support-matrix.md +9 -8
  7. data/docs/architecture.md +22 -4
  8. data/docs/authentication.md +112 -51
  9. data/docs/commands.md +71 -17
  10. data/docs/configuration-schema-v1.json +53 -7
  11. data/docs/configuration.md +33 -9
  12. data/docs/google-client-compatibility.md +10 -6
  13. data/docs/live-smoke-test.md +159 -0
  14. data/docs/plan-format.md +4 -0
  15. data/docs/plan-schema-v1.json +113 -22
  16. data/docs/rails.md +12 -3
  17. data/docs/reports.md +44 -6
  18. data/docs/safety.md +18 -6
  19. data/docs/troubleshooting.md +42 -13
  20. data/lib/analytics_ops/applier.rb +2 -1
  21. data/lib/analytics_ops/cli/options.rb +157 -0
  22. data/lib/analytics_ops/cli/presenter.rb +221 -0
  23. data/lib/analytics_ops/cli.rb +172 -201
  24. data/lib/analytics_ops/clients/admin.rb +130 -129
  25. data/lib/analytics_ops/clients/admin_normalization.rb +95 -0
  26. data/lib/analytics_ops/clients/data.rb +120 -63
  27. data/lib/analytics_ops/clients/error_translation.rb +45 -0
  28. data/lib/analytics_ops/configuration/document.rb +1 -1
  29. data/lib/analytics_ops/configuration/loader.rb +38 -5
  30. data/lib/analytics_ops/configuration/schema.rb +51 -8
  31. data/lib/analytics_ops/configuration/validator.rb +43 -8
  32. data/lib/analytics_ops/configuration/writer.rb +105 -0
  33. data/lib/analytics_ops/configuration.rb +1 -0
  34. data/lib/analytics_ops/connection.rb +93 -0
  35. data/lib/analytics_ops/desired_state.rb +2 -2
  36. data/lib/analytics_ops/plan.rb +94 -33
  37. data/lib/analytics_ops/planner.rb +10 -2
  38. data/lib/analytics_ops/rails/railtie.rb +13 -18
  39. data/lib/analytics_ops/redaction.rb +14 -9
  40. data/lib/analytics_ops/reports/catalog.rb +22 -5
  41. data/lib/analytics_ops/reports/definition.rb +74 -37
  42. data/lib/analytics_ops/reports/overview.rb +55 -0
  43. data/lib/analytics_ops/reports/overview_result.rb +54 -0
  44. data/lib/analytics_ops/reports/result.rb +38 -9
  45. data/lib/analytics_ops/reports.rb +2 -0
  46. data/lib/analytics_ops/resources.rb +2 -1
  47. data/lib/analytics_ops/service_account.rb +171 -0
  48. data/lib/analytics_ops/setup.rb +171 -0
  49. data/lib/analytics_ops/snapshot.rb +20 -5
  50. data/lib/analytics_ops/version.rb +1 -1
  51. data/lib/analytics_ops/workspace.rb +41 -12
  52. data/lib/analytics_ops.rb +4 -0
  53. data/lib/generators/analytics_ops/install_generator.rb +2 -0
  54. data/lib/generators/analytics_ops/templates/analytics_ops.yml +2 -15
  55. data/sig/analytics_ops.rbs +117 -10
  56. metadata +26 -1
@@ -0,0 +1,159 @@
1
+ # Live read-only smoke test
2
+
3
+ Use this guide before releasing Analytics Ops or when checking it in a real
4
+ application. The test reads Google Analytics and writes local setup files. It
5
+ never changes GA4.
6
+
7
+ ## What you need
8
+
9
+ | Item | Purpose | Fake example |
10
+ | --- | --- | --- |
11
+ | Google account | Your human login for Cloud and GA4 administration | `operator@example.test` |
12
+ | GA4 account | Owns one or more GA4 properties | Account ID `100000001` |
13
+ | GA4 property | Holds the configuration and reports being tested | Property ID `123456789` |
14
+ | Google Cloud project | Owns API enablement, quota, and the service account | `example-analytics-project` |
15
+ | Service account | The non-human identity Analytics Ops uses | `analytics-ops@example-project.iam.gserviceaccount.com` |
16
+
17
+ Cloud IAM access and GA4 access are separate. The service account does not
18
+ need a broad Cloud role, but its email must be added inside GA4 Access
19
+ Management.
20
+
21
+ A normal API key cannot read private GA4 data or change GA4 settings.
22
+
23
+ ## Prepare Google once
24
+
25
+ 1. In the Cloud project, enable:
26
+ - Google Analytics Admin API
27
+ - Google Analytics Data API
28
+ 2. Open [Google Cloud service accounts](https://console.cloud.google.com/iam-admin/serviceaccounts).
29
+ 3. Create **Analytics Ops Local** without broad Cloud IAM roles.
30
+ 4. Create a JSON key and save it outside all repositories.
31
+ 5. In GA4, open **Admin → Account access management → Add users**.
32
+ 6. Add the service-account email as Viewer for read-only use or Editor for
33
+ future reviewed applies.
34
+
35
+ No Google Cloud CLI, browser authorization, consent screen, OAuth client, or
36
+ test-user setup is used.
37
+
38
+ ## Test the checkout through a real app
39
+
40
+ Temporarily point the host application's Gemfile at the local gem:
41
+
42
+ ```ruby
43
+ gem "analytics_ops",
44
+ path: "/absolute/path/to/analytics_ops",
45
+ group: :development
46
+ ```
47
+
48
+ Run `bundle install`. `bundle info analytics_ops` must show the local checkout
49
+ and version `0.2.0`.
50
+
51
+ ### Preserve an existing configuration
52
+
53
+ Setup never overwrites a conflicting profile. If
54
+ `config/analytics_ops.yml` already exists, hold it safely outside the
55
+ application while testing:
56
+
57
+ ```bash
58
+ analytics_smoke_backup="$(mktemp -d)"
59
+ cp config/analytics_ops.yml "$analytics_smoke_backup/original.yml"
60
+ mv config/analytics_ops.yml "$analytics_smoke_backup/held.yml"
61
+ ```
62
+
63
+ Do not print the file. Production identifiers and environment conventions do
64
+ not belong in public test output.
65
+
66
+ ### Connect once
67
+
68
+ Use the downloaded key without printing its contents:
69
+
70
+ ```bash
71
+ bundle exec analytics-ops setup \
72
+ --service-account /absolute/path/outside/repositories/service-account.json
73
+ ```
74
+
75
+ Analytics Ops validates the key, verifies both APIs, and remembers only its
76
+ absolute path in `~/.config/analytics_ops/connection.json` with mode `0600`.
77
+ The key is not copied.
78
+
79
+ Run plain setup once more to verify the remembered connection:
80
+
81
+ ```bash
82
+ bundle exec analytics-ops setup
83
+ ```
84
+
85
+ ### Run every read-only check
86
+
87
+ ```bash
88
+ bundle exec analytics-ops version
89
+ bundle info analytics_ops
90
+ bundle exec analytics-ops doctor
91
+ bundle exec analytics-ops overview
92
+ bundle exec analytics-ops report traffic --json
93
+ bundle exec analytics-ops realtime
94
+ bundle exec analytics-ops audit
95
+ ```
96
+
97
+ Empty reports are acceptable for a property with little traffic.
98
+ Authentication errors, authorization errors, malformed output, crashes, and
99
+ Google-client errors are failures.
100
+
101
+ `audit` exit status `0` means the declared state converges. Exit status `2`
102
+ means drift was found. Both are successful read-only smoke-test outcomes.
103
+
104
+ During the test:
105
+
106
+ - Never run `analytics-ops apply`.
107
+ - Do not create a plan for application.
108
+ - Never commit the service-account JSON, the connection pointer, real report
109
+ rows, screenshots containing credentials, or temporary production IDs.
110
+ - Never paste key contents into a terminal transcript, issue, or chat.
111
+
112
+ ## Restore the host app
113
+
114
+ Restore the original configuration:
115
+
116
+ ```bash
117
+ mv config/analytics_ops.yml "$analytics_smoke_backup/generated.yml"
118
+ cp "$analytics_smoke_backup/original.yml" config/analytics_ops.yml
119
+ cmp "$analytics_smoke_backup/original.yml" config/analytics_ops.yml
120
+ ```
121
+
122
+ After confirming the restoration, remove the temporary backup with the
123
+ operating system's normal secure cleanup process.
124
+
125
+ Keep `~/.config/analytics_ops/connection.json`, the Cloud project, and the
126
+ service account if Analytics Ops will remain in use. For a temporary-only
127
+ test, remove the connection file and revoke the downloaded key in Google
128
+ Cloud.
129
+
130
+ Keep the local path dependency until `0.2.0` is published. After publication,
131
+ replace it with:
132
+
133
+ ```ruby
134
+ gem "analytics_ops", "~> 0.2", group: :development
135
+ ```
136
+
137
+ Run `bundle install`, then confirm `bundle info analytics_ops` resolves
138
+ version `0.2.0` from RubyGems.
139
+
140
+ ## Release gate
141
+
142
+ Do not tag while any live read-only command or local release check is failing.
143
+ Require all of the following:
144
+
145
+ - The complete live smoke test passed.
146
+ - `bin/check`, package inspection, and `git diff --check` passed.
147
+ - Documentation and changelog describe the released behavior.
148
+ - The gem worktree is clean and the release commit is on `origin/master`.
149
+ - Version and changelog both say `0.2.0`.
150
+
151
+ Then create and push the annotated tag:
152
+
153
+ ```bash
154
+ git tag -a v0.2.0 -m "Release Analytics Ops 0.2.0"
155
+ git push origin v0.2.0
156
+ ```
157
+
158
+ The tag workflow verifies and publishes through RubyGems Trusted Publishing.
159
+ Never run a manual `gem push` or store a RubyGems API key.
data/docs/plan-format.md CHANGED
@@ -63,6 +63,10 @@ before/after payloads, reversibility, and manual rollback guidance.
63
63
  Resource names in update payloads must belong to the plan's property. Immutable
64
64
  identity fields cannot change between `before` and `after`.
65
65
 
66
+ Currency custom-metric payloads include `restricted_metric_types` with
67
+ `cost_data`, `revenue_data`, or both. The classification is never inferred or
68
+ changed automatically.
69
+
66
70
  ## Findings are not operations
67
71
 
68
72
  A finding may identify inaccessible resources, immutable conflicts, manual UI
@@ -53,8 +53,8 @@
53
53
  "resource_type": { "const": "data_stream" },
54
54
  "resource_identity": { "pattern": "^stream:[0-9]{1,50}$" },
55
55
  "operation": { "const": "update" },
56
- "before": { "$ref": "#/$defs/dataStream" },
57
- "after": { "$ref": "#/$defs/dataStream" }
56
+ "before": { "$ref": "#/$defs/dataStreamBefore" },
57
+ "after": { "$ref": "#/$defs/dataStreamAfter" }
58
58
  }
59
59
  },
60
60
  {
@@ -124,17 +124,38 @@
124
124
  "message": { "type": "string", "pattern": "^[^\\u0000-\\u001F\\u007F]{1,1000}$" }
125
125
  }
126
126
  },
127
- "dataStream": {
127
+ "dataStreamBefore": {
128
128
  "type": "object",
129
129
  "additionalProperties": false,
130
130
  "required": ["id", "name", "display_name", "type", "default_uri", "measurement_id"],
131
131
  "properties": {
132
132
  "id": { "$ref": "#/$defs/id" },
133
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 }
134
+ "display_name": { "type": "string", "maxLength": 100, "pattern": "^[^\\u0000-\\u001F\\u007F]*$" },
135
+ "type": { "const": "web" },
136
+ "default_uri": {
137
+ "type": ["string", "null"], "format": "uri", "pattern": "^[^\\u0000-\\u001F\\u007F]+$"
138
+ },
139
+ "measurement_id": {
140
+ "type": ["string", "null"], "maxLength": 64, "pattern": "^[^\\u0000-\\u001F\\u007F]+$"
141
+ }
142
+ }
143
+ },
144
+ "dataStreamAfter": {
145
+ "type": "object",
146
+ "additionalProperties": false,
147
+ "required": ["id", "name", "display_name", "type", "default_uri", "measurement_id"],
148
+ "properties": {
149
+ "id": { "$ref": "#/$defs/id" },
150
+ "name": { "$ref": "#/$defs/resourceName" },
151
+ "display_name": { "type": "string", "maxLength": 100, "pattern": "^[^\\u0000-\\u001F\\u007F]*$" },
152
+ "type": { "const": "web" },
153
+ "default_uri": {
154
+ "type": "string", "format": "uri", "pattern": "^[^\\u0000-\\u001F\\u007F]+$"
155
+ },
156
+ "measurement_id": {
157
+ "type": ["string", "null"], "maxLength": 64, "pattern": "^[^\\u0000-\\u001F\\u007F]+$"
158
+ }
138
159
  }
139
160
  },
140
161
  "retention": {
@@ -144,13 +165,16 @@
144
165
  "properties": {
145
166
  "name": { "$ref": "#/$defs/resourceName" },
146
167
  "event_data": { "$ref": "#/$defs/retentionValue" },
147
- "user_data": { "$ref": "#/$defs/retentionValue" },
168
+ "user_data": { "$ref": "#/$defs/userRetentionValue" },
148
169
  "reset_on_new_activity": { "type": "boolean" }
149
170
  }
150
171
  },
151
172
  "retentionValue": {
152
173
  "enum": ["2_months", "14_months", "26_months", "38_months", "50_months"]
153
174
  },
175
+ "userRetentionValue": {
176
+ "enum": ["2_months", "14_months"]
177
+ },
154
178
  "keyEvent": {
155
179
  "type": "object",
156
180
  "additionalProperties": false,
@@ -166,11 +190,28 @@
166
190
  "required": ["parameter_name", "display_name", "description", "scope", "disallow_ads_personalization"],
167
191
  "properties": {
168
192
  "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 },
193
+ "display_name": {
194
+ "type": "string", "minLength": 1, "maxLength": 82,
195
+ "pattern": "^[^\\u0000-\\u001F\\u007F]+$"
196
+ },
197
+ "description": { "type": "string", "maxLength": 150, "pattern": "^[^\\u0000-\\u001F\\u007F]*$" },
171
198
  "scope": { "enum": ["event", "user", "item"] },
172
199
  "disallow_ads_personalization": { "type": "boolean" }
173
- }
200
+ },
201
+ "allOf": [
202
+ {
203
+ "if": { "properties": { "scope": { "const": "user" } } },
204
+ "then": {
205
+ "properties": {
206
+ "parameter_name": { "type": "string", "pattern": "^[A-Za-z][A-Za-z0-9_]{0,23}$" }
207
+ }
208
+ }
209
+ },
210
+ {
211
+ "if": { "properties": { "scope": { "enum": ["event", "item"] } } },
212
+ "then": { "properties": { "disallow_ads_personalization": { "const": false } } }
213
+ }
214
+ ]
174
215
  },
175
216
  "customDimensionUpdate": {
176
217
  "type": "object",
@@ -179,46 +220,96 @@
179
220
  "properties": {
180
221
  "name": { "$ref": "#/$defs/resourceName" },
181
222
  "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 },
223
+ "display_name": {
224
+ "type": "string", "minLength": 1, "maxLength": 82,
225
+ "pattern": "^[^\\u0000-\\u001F\\u007F]+$"
226
+ },
227
+ "description": { "type": "string", "maxLength": 150, "pattern": "^[^\\u0000-\\u001F\\u007F]*$" },
184
228
  "scope": { "enum": ["event", "user", "item"] },
185
229
  "disallow_ads_personalization": { "type": "boolean" }
186
- }
230
+ },
231
+ "allOf": [
232
+ {
233
+ "if": { "properties": { "scope": { "const": "user" } } },
234
+ "then": {
235
+ "properties": {
236
+ "parameter_name": { "type": "string", "pattern": "^[A-Za-z][A-Za-z0-9_]{0,23}$" }
237
+ }
238
+ }
239
+ },
240
+ {
241
+ "if": { "properties": { "scope": { "enum": ["event", "item"] } } },
242
+ "then": { "properties": { "disallow_ads_personalization": { "const": false } } }
243
+ }
244
+ ]
187
245
  },
188
246
  "customMetricCreate": {
189
247
  "type": "object",
190
248
  "additionalProperties": false,
191
- "required": ["parameter_name", "display_name", "description", "scope", "measurement_unit"],
249
+ "required": [
250
+ "parameter_name", "display_name", "description", "scope", "measurement_unit", "restricted_metric_types"
251
+ ],
192
252
  "properties": {
193
253
  "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 },
254
+ "display_name": {
255
+ "type": "string", "minLength": 1, "maxLength": 82,
256
+ "pattern": "^[^\\u0000-\\u001F\\u007F]+$"
257
+ },
258
+ "description": { "type": "string", "maxLength": 150, "pattern": "^[^\\u0000-\\u001F\\u007F]*$" },
196
259
  "scope": { "const": "event" },
197
260
  "measurement_unit": {
198
261
  "enum": [
199
262
  "standard", "currency", "feet", "meters", "kilometers", "miles",
200
263
  "milliseconds", "seconds", "minutes", "hours"
201
264
  ]
265
+ },
266
+ "restricted_metric_types": {
267
+ "type": "array", "uniqueItems": true,
268
+ "items": { "enum": ["cost_data", "revenue_data"] }
202
269
  }
203
- }
270
+ },
271
+ "allOf": [
272
+ {
273
+ "if": { "properties": { "measurement_unit": { "const": "currency" } } },
274
+ "then": { "properties": { "restricted_metric_types": { "minItems": 1 } } },
275
+ "else": { "properties": { "restricted_metric_types": { "maxItems": 0 } } }
276
+ }
277
+ ]
204
278
  },
205
279
  "customMetricUpdate": {
206
280
  "type": "object",
207
281
  "additionalProperties": false,
208
- "required": ["name", "parameter_name", "display_name", "description", "scope", "measurement_unit"],
282
+ "required": [
283
+ "name", "parameter_name", "display_name", "description", "scope", "measurement_unit",
284
+ "restricted_metric_types"
285
+ ],
209
286
  "properties": {
210
287
  "name": { "$ref": "#/$defs/resourceName" },
211
288
  "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 },
289
+ "display_name": {
290
+ "type": "string", "minLength": 1, "maxLength": 82,
291
+ "pattern": "^[^\\u0000-\\u001F\\u007F]+$"
292
+ },
293
+ "description": { "type": "string", "maxLength": 150, "pattern": "^[^\\u0000-\\u001F\\u007F]*$" },
214
294
  "scope": { "const": "event" },
215
295
  "measurement_unit": {
216
296
  "enum": [
217
297
  "standard", "currency", "feet", "meters", "kilometers", "miles",
218
298
  "milliseconds", "seconds", "minutes", "hours"
219
299
  ]
300
+ },
301
+ "restricted_metric_types": {
302
+ "type": "array", "uniqueItems": true,
303
+ "items": { "enum": ["cost_data", "revenue_data"] }
220
304
  }
221
- }
305
+ },
306
+ "allOf": [
307
+ {
308
+ "if": { "properties": { "measurement_unit": { "const": "currency" } } },
309
+ "then": { "properties": { "restricted_metric_types": { "minItems": 1 } } },
310
+ "else": { "properties": { "restricted_metric_types": { "maxItems": 0 } } }
311
+ }
312
+ ]
222
313
  }
223
314
  }
224
315
  }
data/docs/rails.md CHANGED
@@ -14,6 +14,9 @@ Active Support.
14
14
 
15
15
  ```bash
16
16
  bundle install
17
+ bundle exec analytics-ops setup \
18
+ --profile development \
19
+ --service-account /absolute/path/to/service-account.json
17
20
  bin/rails generate analytics_ops:install
18
21
  ```
19
22
 
@@ -22,14 +25,20 @@ The generator creates only:
22
25
  - `config/analytics_ops.yml`
23
26
  - `bin/analytics-ops` with executable mode
24
27
 
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
+ If setup already created the configuration, the generator preserves it and
29
+ adds only the binstub. It never asks to overwrite the selected property.
30
+
31
+ The generated file has a minimal `development` profile and a fake-safe
32
+ property-ID placeholder. It contains no active stream, retention, key-event,
33
+ dimension, or metric policy, so installation alone cannot produce a mutation
34
+ plan. Add managed settings only after reviewing the configuration guide. Add
35
+ a profile matching another Rails environment, or set `ANALYTICS_OPS_PROFILE`.
28
36
 
29
37
  ## Rake tasks
30
38
 
31
39
  ```bash
32
40
  bin/rake analytics:doctor
41
+ bin/rake analytics:overview
33
42
  bin/rake analytics:audit
34
43
  bin/rake analytics:plan
35
44
  bin/rake analytics:verify
data/docs/reports.md CHANGED
@@ -17,7 +17,11 @@ All standard recipes cover the previous 28 complete days
17
17
  | `shares_and_prints` | Share and print events by calculator | `customEvent:calculator_slug` |
18
18
  | `related_calculator_navigation` | Related-calculator clicks by source and destination | `customEvent:calculator_slug`, `customEvent:related_calculator_slug` |
19
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 |
20
+ | `realtime_events` | Event count by event name | None |
21
+
22
+ The CLI also accepts `traffic` as an alias for `traffic_acquisition` and
23
+ `landing-pages` as an alias for `landing_pages`. Canonical names remain in
24
+ JSON and Ruby results.
21
25
 
22
26
  The event names used by the calculator recipes are
23
27
  `calculation_completed`, `result_shared`, `result_printed`,
@@ -27,15 +31,39 @@ If your site uses different names, create a custom Ruby definition.
27
31
  ## CLI
28
32
 
29
33
  ```bash
30
- analytics-ops report traffic_acquisition
31
- analytics-ops report landing_pages --format json
32
- analytics-ops report calculator_completions --format csv
34
+ analytics-ops overview
35
+ analytics-ops report traffic
36
+ analytics-ops report landing-pages --json
37
+ analytics-ops report calculator_completions --csv
33
38
  analytics-ops realtime
34
39
  ```
35
40
 
36
41
  CSV is accepted only for `report` and `realtime`. The CSV renderer protects
37
- cells that start with `=`, `+`, `-`, or `@` from spreadsheet formula
38
- execution.
42
+ headers and cells whose first meaningful character is `=`, `+`, `-`, or `@`,
43
+ including formula text hidden behind whitespace or control characters.
44
+
45
+ ## Overview
46
+
47
+ `analytics-ops overview` uses one `batchRunReports` call containing five
48
+ small reports for the previous 28 complete days:
49
+
50
+ - totals for active users, sessions, and key events
51
+ - a daily trend
52
+ - traffic acquisition
53
+ - landing pages
54
+ - device categories
55
+
56
+ Each subreport has a small row limit, and Google property-quota information is
57
+ preserved when returned. Batching reduces network round trips; it does not
58
+ make the underlying report work quota-free. CSV is intentionally unavailable
59
+ for this multi-report result. Use `--json` for structured overview output.
60
+
61
+ The dimensionless totals request keeps empty rows so a brand-new property
62
+ still returns the expected metric headers.
63
+
64
+ The realtime recipe intentionally requests only `eventCount` with
65
+ `eventName`. Google does not allow `activeUsers` with that dimension. Realtime
66
+ is a short-lived event check rather than a complete user report.
39
67
 
40
68
  ## Ruby
41
69
 
@@ -49,6 +77,10 @@ result = workspace.report("traffic_acquisition")
49
77
  puts result.headers
50
78
  puts result.rows
51
79
  puts result.metadata
80
+
81
+ overview = workspace.overview
82
+ puts overview.report("overview_totals").rows
83
+ puts overview.property_quota
52
84
  ```
53
85
 
54
86
  Custom immutable definition:
@@ -82,6 +114,12 @@ metrics, invalid date order, filters that reference unselected fields,
82
114
  non-finite numeric filters, invalid ordering, and unbounded limits. Realtime
83
115
  definitions cannot use date ranges or offsets.
84
116
 
117
+ Dates use only `YYYY-MM-DD`, `NdaysAgo`, `yesterday`, or `today`. Absolute and
118
+ relative ranges are checked for reversed endpoints. A request may contain up
119
+ to four uniquely named ranges; comparison responses include Google's
120
+ automatic `dateRange` dimension. Numeric filters accept only finite doubles or
121
+ signed 64-bit integers.
122
+
85
123
  ## Result shape
86
124
 
87
125
  `result.to_h` contains:
data/docs/safety.md CHANGED
@@ -4,9 +4,12 @@ Analytics Ops is read-only unless you explicitly apply a saved plan.
4
4
 
5
5
  ## What cannot mutate
6
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.
7
+ `properties`, `doctor`, `discover`, `snapshot`, `audit`, `plan`, `verify`,
8
+ `overview`, `report`, and `realtime` only read Google Analytics APIs. Setup
9
+ validates a service-account key, verifies access, remembers only its path, and
10
+ creates local configuration; it never changes Analytics. Loading the gem,
11
+ constructing a connection, parsing YAML, and booting Rails make no network
12
+ request.
10
13
 
11
14
  ## What apply requires
12
15
 
@@ -20,6 +23,9 @@ An apply needs all of these:
20
23
  The applier executes only the operations stored in the file. It never replans
21
24
  during apply and never turns findings into changes.
22
25
 
26
+ The Ruby API also requires the literal boolean `true`; truthy strings or
27
+ objects do not count as confirmation.
28
+
23
29
  ## Stale plans
24
30
 
25
31
  Every plan fingerprints normalized managed remote state with SHA-256. If that
@@ -74,7 +80,7 @@ with mode 0600. Loading rejects:
74
80
  - unknown resource payload fields
75
81
  - unsupported operations
76
82
  - no-op updates and immutable-field changes
77
- - secret-shaped fields and control characters
83
+ - secret-shaped fields, credential-shaped text, and control characters
78
84
  - resource names for another property
79
85
 
80
86
  Workspace and adapter checks provide additional cross-property protection.
@@ -83,11 +89,17 @@ The public schema is [plan-schema-v1.json](plan-schema-v1.json).
83
89
  ## Credential and data boundaries
84
90
 
85
91
  - Credentials never belong in YAML, plans, fixtures, logs, or command output.
92
+ - `~/.config/analytics_ops/connection.json` contains only a key path, is
93
+ atomic, and uses mode `0600`; the key is never copied.
94
+ - Ambient Application Default Credentials and API keys are never used.
95
+ - Read-only commands request only `analytics.readonly`; guarded apply also
96
+ requests `analytics.edit`.
86
97
  - Report rows are never logged automatically.
87
98
  - The gem has no telemetry and stores no report results.
88
99
  - The gem does not inject browser analytics or manage consent.
89
100
  - Production mutation credentials should not exist in a Rails web container.
90
101
  - Real production IDs and exports do not belong in repository fixtures.
91
102
 
92
- Use a read-only identity for routine audits and reports. Use a separately
93
- protected edit identity only for a reviewed apply.
103
+ Use a Viewer service account for reports-only installations. If one service
104
+ account has the Editor role, Analytics Ops still requests edit scope only
105
+ inside the reviewed apply path.
@@ -3,10 +3,13 @@
3
3
  Start with:
4
4
 
5
5
  ```bash
6
- analytics-ops doctor --format json
6
+ analytics-ops setup --service-account /absolute/path/to/service-account.json
7
+ analytics-ops properties
8
+ analytics-ops doctor --json
7
9
  ```
8
10
 
9
- The command is read-only and checks both Google APIs.
11
+ `properties` works without configuration. Setup verifies both APIs and creates
12
+ the minimal file; doctor performs the complete configured-property check.
10
13
 
11
14
  ## Configuration error
12
15
 
@@ -23,28 +26,51 @@ stream_id: "987654321"
23
26
 
24
27
  Do not use account ID `100000001` or measurement ID `G-EXAMPLE1` in those
25
28
  fields. Unknown keys, ERB, YAML aliases, missing environment variables, and
26
- secret-shaped fields are intentionally rejected.
29
+ secret- or credential-shaped material are intentionally rejected.
30
+
31
+ Currency custom metrics require an explicit restricted-data classification:
32
+
33
+ ```yaml
34
+ measurement_unit: currency
35
+ restricted_metric_types: [revenue_data]
36
+ ```
37
+
38
+ Use `cost_data`, `revenue_data`, or both according to the metric's meaning.
39
+ Analytics Ops will not guess. User-data retention accepts only `2_months` or
40
+ `14_months`; longer 360 periods apply only to event data.
27
41
 
28
42
  ## Authentication failure (exit 66)
29
43
 
30
- Create ADC again and ensure both APIs are enabled:
44
+ Analytics Ops could not load or use the configured service account.
31
45
 
32
46
  ```bash
33
- gcloud auth application-default login \
34
- --scopes="https://www.googleapis.com/auth/cloud-platform,https://www.googleapis.com/auth/analytics.readonly"
47
+ analytics-ops setup \
48
+ --service-account /absolute/path/outside/repositories/service-account.json
35
49
  ```
36
50
 
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.
51
+ Check that:
52
+
53
+ - the file still exists at that path
54
+ - it is a JSON key whose `type` is `service_account`
55
+ - the key has not been revoked in Google Cloud
56
+ - the Analytics Admin and Data APIs are enabled in its Cloud project
57
+ - the service-account email is added in GA4 Access Management
58
+
59
+ The saved pointer is `~/.config/analytics_ops/connection.json`. It contains
60
+ only the key path. Rerunning setup with `--service-account` safely replaces
61
+ that pointer after API verification succeeds.
62
+
63
+ Analytics Ops does not fall back to `gcloud`, browser login, Application
64
+ Default Credentials, `GOOGLE_APPLICATION_CREDENTIALS`, or API keys.
40
65
 
41
66
  ## Permission failure (exit 77)
42
67
 
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.
68
+ The service-account identity needs access inside the GA4 property. Cloud IAM
69
+ alone is not enough. Add its email to GA4 Access Management with Viewer for
70
+ reads or Editor for plan application.
46
71
 
47
- Use `analytics-ops discover` to see accessible numeric property IDs.
72
+ Use `analytics-ops properties` to see accessible numeric property IDs without
73
+ creating configuration. `discover` additionally retrieves streams.
48
74
 
49
75
  ## API or remote failure (exit 69)
50
76
 
@@ -53,6 +79,9 @@ credential's Cloud project. Check the configuration ID and the error's typed
53
79
  message. Invalid dimensions, metrics, custom definitions, or property
54
80
  restrictions can also produce this status.
55
81
 
82
+ When setup recognizes disabled APIs, enable both APIs in the Google Cloud
83
+ project that owns the service account.
84
+
56
85
  ## Quota (exit 75) or timeout (exit 74)
57
86
 
58
87
  Wait for Google quota to recover, reduce report frequency, or use a positive
@@ -15,7 +15,8 @@ module AnalyticsOps
15
15
  end
16
16
 
17
17
  def call(plan, confirm: false)
18
- raise ConfirmationRequiredError, "Applying a plan requires explicit confirmation" unless confirm
18
+ raise ConfirmationRequiredError, "Applying a plan requires explicit boolean confirmation" unless confirm == true
19
+ raise InvalidPlanError, "plan must be an AnalyticsOps::Plan" unless plan.is_a?(Plan)
19
20
 
20
21
  current = @admin.snapshot(plan.property_id)
21
22
  unless current.fingerprint == plan.snapshot_fingerprint