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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +47 -0
- data/CODE_OF_CONDUCT.md +10 -0
- data/CONTRIBUTING.md +67 -0
- data/LICENSE.txt +21 -0
- data/README.md +188 -0
- data/SECURITY.md +56 -0
- data/docs/api-support-matrix.md +50 -0
- data/docs/architecture.md +89 -0
- data/docs/authentication.md +86 -0
- data/docs/commands.md +112 -0
- data/docs/configuration-schema-v1.json +129 -0
- data/docs/configuration.md +167 -0
- data/docs/google-client-compatibility.md +59 -0
- data/docs/plan-format.md +89 -0
- data/docs/plan-schema-v1.json +224 -0
- data/docs/rails.md +73 -0
- data/docs/reports.md +124 -0
- data/docs/safety.md +93 -0
- data/docs/troubleshooting.md +103 -0
- data/exe/analytics-ops +6 -0
- data/lib/analytics_ops/applier.rb +52 -0
- data/lib/analytics_ops/canonical.rb +65 -0
- data/lib/analytics_ops/cli.rb +427 -0
- data/lib/analytics_ops/clients/admin.rb +446 -0
- data/lib/analytics_ops/clients/data.rb +314 -0
- data/lib/analytics_ops/configuration/document.rb +25 -0
- data/lib/analytics_ops/configuration/loader.rb +76 -0
- data/lib/analytics_ops/configuration/schema.rb +107 -0
- data/lib/analytics_ops/configuration/validator.rb +344 -0
- data/lib/analytics_ops/configuration.rb +19 -0
- data/lib/analytics_ops/desired_state.rb +40 -0
- data/lib/analytics_ops/errors.rb +31 -0
- data/lib/analytics_ops/plan.rb +551 -0
- data/lib/analytics_ops/planner.rb +233 -0
- data/lib/analytics_ops/rails/railtie.rb +65 -0
- data/lib/analytics_ops/rails.rb +5 -0
- data/lib/analytics_ops/redaction.rb +46 -0
- data/lib/analytics_ops/reports/catalog.rb +100 -0
- data/lib/analytics_ops/reports/definition.rb +226 -0
- data/lib/analytics_ops/reports/result.rb +90 -0
- data/lib/analytics_ops/reports.rb +13 -0
- data/lib/analytics_ops/resources.rb +79 -0
- data/lib/analytics_ops/snapshot.rb +45 -0
- data/lib/analytics_ops/version.rb +5 -0
- data/lib/analytics_ops/workspace.rb +212 -0
- data/lib/analytics_ops.rb +21 -0
- data/lib/generators/analytics_ops/install_generator.rb +23 -0
- data/lib/generators/analytics_ops/templates/analytics-ops +9 -0
- data/lib/generators/analytics_ops/templates/analytics_ops.yml +23 -0
- data/sig/analytics_ops.rbs +395 -0
- 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,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
|