analytics_ops 0.2.0 → 0.3.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 +4 -4
- data/CHANGELOG.md +108 -0
- data/CONTRIBUTING.md +1 -0
- data/README.md +141 -13
- data/SECURITY.md +11 -2
- data/docs/ai-connections.md +192 -0
- data/docs/api-support-matrix.md +25 -8
- data/docs/architecture.md +78 -17
- data/docs/authentication.md +53 -3
- data/docs/bigquery.md +128 -0
- data/docs/commands.md +133 -12
- data/docs/configuration-schema-v1.json +608 -50
- data/docs/configuration.md +118 -5
- data/docs/google-client-compatibility.md +21 -1
- data/docs/governance.md +93 -0
- data/docs/health.md +82 -0
- data/docs/live-smoke-test.md +13 -10
- data/docs/rails.md +25 -9
- data/docs/reports.md +165 -3
- data/docs/safety.md +37 -5
- data/docs/troubleshooting.md +81 -7
- data/lib/analytics_ops/bigquery/definition.rb +173 -0
- data/lib/analytics_ops/bigquery.rb +4 -0
- data/lib/analytics_ops/cli/local_commands.rb +80 -0
- data/lib/analytics_ops/cli/options.rb +282 -9
- data/lib/analytics_ops/cli/presenter.rb +263 -28
- data/lib/analytics_ops/cli/presenter_csv.rb +92 -0
- data/lib/analytics_ops/cli/reporting_commands.rb +129 -0
- data/lib/analytics_ops/cli/setup_commands.rb +91 -0
- data/lib/analytics_ops/cli.rb +121 -44
- data/lib/analytics_ops/clients/admin.rb +28 -2
- data/lib/analytics_ops/clients/admin_governance.rb +95 -0
- data/lib/analytics_ops/clients/admin_governance_normalization.rb +90 -0
- data/lib/analytics_ops/clients/admin_resource_normalization.rb +61 -0
- data/lib/analytics_ops/clients/bigquery.rb +313 -0
- data/lib/analytics_ops/clients/data.rb +147 -54
- data/lib/analytics_ops/clients/data_result_normalization.rb +55 -0
- data/lib/analytics_ops/clients/error_translation.rb +13 -1
- data/lib/analytics_ops/configuration/schema.rb +175 -1
- data/lib/analytics_ops/configuration/validator.rb +45 -7
- data/lib/analytics_ops/configuration/validator_experimental.rb +136 -0
- data/lib/analytics_ops/configuration/validator_reporting.rb +93 -0
- data/lib/analytics_ops/configuration/writer.rb +112 -7
- data/lib/analytics_ops/desired_state.rb +28 -3
- data/lib/analytics_ops/errors.rb +33 -1
- data/lib/analytics_ops/funnels.rb +58 -0
- data/lib/analytics_ops/governance.rb +363 -0
- data/lib/analytics_ops/health.rb +310 -0
- data/lib/analytics_ops/mcp_server/custom_report_tool.rb +101 -0
- data/lib/analytics_ops/mcp_server/discovery_tools.rb +79 -0
- data/lib/analytics_ops/mcp_server/experimental_tools.rb +89 -0
- data/lib/analytics_ops/mcp_server/health_tools.rb +80 -0
- data/lib/analytics_ops/mcp_server/overview_tools.rb +49 -0
- data/lib/analytics_ops/mcp_server/standard_report_tool.rb +53 -0
- data/lib/analytics_ops/mcp_server.rb +402 -0
- data/lib/analytics_ops/portfolio.rb +216 -0
- data/lib/analytics_ops/rails/railtie.rb +19 -8
- data/lib/analytics_ops/redaction.rb +10 -6
- data/lib/analytics_ops/reports/csv_export.rb +91 -0
- data/lib/analytics_ops/reports/definition.rb +152 -14
- data/lib/analytics_ops/reports/metadata.rb +158 -0
- data/lib/analytics_ops/reports/period.rb +104 -0
- data/lib/analytics_ops/reports/result.rb +8 -3
- data/lib/analytics_ops/reports.rb +3 -0
- data/lib/analytics_ops/service_account.rb +321 -29
- data/lib/analytics_ops/setup.rb +31 -7
- data/lib/analytics_ops/version.rb +1 -1
- data/lib/analytics_ops/workspace.rb +222 -10
- data/lib/analytics_ops.rb +7 -2
- data/lib/generators/analytics_ops/templates/analytics_ops.yml +1 -1
- data/sig/analytics_ops.rbs +494 -9
- metadata +60 -1
data/docs/reports.md
CHANGED
|
@@ -38,9 +38,137 @@ analytics-ops report calculator_completions --csv
|
|
|
38
38
|
analytics-ops realtime
|
|
39
39
|
```
|
|
40
40
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
41
|
+
List every built-in and saved recipe:
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
analytics-ops reports
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Change dates without writing Ruby:
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
analytics-ops report traffic --last 7
|
|
51
|
+
analytics-ops report traffic --from 2026-07-01 --to 2026-07-07
|
|
52
|
+
analytics-ops report traffic --last 30 --compare
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
`--last` ends at yesterday so it uses complete days. `--from` and `--to` are
|
|
56
|
+
inclusive and must be used together. `--compare` adds the equally long period
|
|
57
|
+
immediately before the requested range. Comparison rows contain Google's
|
|
58
|
+
automatic `dateRange` dimension with `current` or `previous`.
|
|
59
|
+
|
|
60
|
+
CSV is accepted for individual reports, realtime, portfolio reports, and
|
|
61
|
+
optional BigQuery/funnel results. The CSV renderer protects headers and cells
|
|
62
|
+
whose first meaningful character is `=`, `+`, `-`, or `@`, including formula
|
|
63
|
+
text hidden behind whitespace or control characters.
|
|
64
|
+
|
|
65
|
+
## Find fields and build a custom report
|
|
66
|
+
|
|
67
|
+
Google's available fields can differ by property. Search the live metadata
|
|
68
|
+
catalog instead of guessing:
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
analytics-ops fields purchase
|
|
72
|
+
analytics-ops fields event --kind dimension
|
|
73
|
+
analytics-ops fields revenue --kind metric
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Then run a small one-off report:
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
analytics-ops report custom \
|
|
80
|
+
--dimension eventName \
|
|
81
|
+
--metric eventCount \
|
|
82
|
+
--where eventName=purchase \
|
|
83
|
+
--last 7 \
|
|
84
|
+
--limit 100
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Repeat `--dimension`, `--metric`, or `--where` when needed. CLI filters are
|
|
88
|
+
exact dimension matches and use the easy `FIELD=VALUE` form. Analytics Ops
|
|
89
|
+
checks live metadata and Google's compatibility endpoint before running a
|
|
90
|
+
custom or saved standard report. Saved realtime definitions are checked
|
|
91
|
+
against live field metadata; Google then performs the realtime-specific field
|
|
92
|
+
pairing validation because its compatibility endpoint covers standard
|
|
93
|
+
reports.
|
|
94
|
+
|
|
95
|
+
Check a saved recipe without running it:
|
|
96
|
+
|
|
97
|
+
```bash
|
|
98
|
+
analytics-ops compatibility purchases_by_country
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Metadata is cached only for the current process. Analytics Ops does not create
|
|
102
|
+
a local field database.
|
|
103
|
+
|
|
104
|
+
## Save reusable recipes
|
|
105
|
+
|
|
106
|
+
Store a recipe under the property profile:
|
|
107
|
+
|
|
108
|
+
```yaml
|
|
109
|
+
profiles:
|
|
110
|
+
production:
|
|
111
|
+
property_id: "123456789"
|
|
112
|
+
|
|
113
|
+
reports:
|
|
114
|
+
purchases_by_country:
|
|
115
|
+
dimensions:
|
|
116
|
+
- country
|
|
117
|
+
- eventName
|
|
118
|
+
metrics:
|
|
119
|
+
- eventCount
|
|
120
|
+
- totalUsers
|
|
121
|
+
dimension_filter:
|
|
122
|
+
and:
|
|
123
|
+
- field: eventName
|
|
124
|
+
match_type: exact
|
|
125
|
+
value: purchase
|
|
126
|
+
- field: country
|
|
127
|
+
in_list: [US, CA]
|
|
128
|
+
metric_filter:
|
|
129
|
+
field: eventCount
|
|
130
|
+
operation: greater_than
|
|
131
|
+
value: 0
|
|
132
|
+
order_bys:
|
|
133
|
+
- metric: eventCount
|
|
134
|
+
desc: true
|
|
135
|
+
limit: 500
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
Run it like a built-in:
|
|
139
|
+
|
|
140
|
+
```bash
|
|
141
|
+
analytics-ops report purchases_by_country
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
Filters can use bounded `and`, `or`, and `not` groups. Dimension leaves support
|
|
145
|
+
string matching, `in_list`, and null checks. Metric leaves support numeric
|
|
146
|
+
comparisons, inclusive `between`, and null checks. Definitions are limited in
|
|
147
|
+
depth, child count, field count, and row count.
|
|
148
|
+
|
|
149
|
+
## Export more rows safely
|
|
150
|
+
|
|
151
|
+
One normal report request is intentionally bounded. For a larger CSV, make the
|
|
152
|
+
limit explicit:
|
|
153
|
+
|
|
154
|
+
```bash
|
|
155
|
+
analytics-ops report traffic \
|
|
156
|
+
--all \
|
|
157
|
+
--page-size 100000 \
|
|
158
|
+
--max-rows 500000 \
|
|
159
|
+
--output tmp/traffic.csv
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
The export:
|
|
163
|
+
|
|
164
|
+
- pages through the Data API up to the hard maximum
|
|
165
|
+
- writes as it receives each page instead of keeping all rows in memory
|
|
166
|
+
- keeps headers consistent across pages
|
|
167
|
+
- protects spreadsheet formula cells
|
|
168
|
+
- replaces the destination atomically with mode `0600`
|
|
169
|
+
|
|
170
|
+
The absolute product maximum is 1,000,000 exported rows. Use BigQuery for
|
|
171
|
+
larger event-level analysis.
|
|
44
172
|
|
|
45
173
|
## Overview
|
|
46
174
|
|
|
@@ -65,6 +193,36 @@ The realtime recipe intentionally requests only `eventCount` with
|
|
|
65
193
|
`eventName`. Google does not allow `activeUsers` with that dimension. Realtime
|
|
66
194
|
is a short-lived event check rather than a complete user report.
|
|
67
195
|
|
|
196
|
+
## Multiple-property portfolio
|
|
197
|
+
|
|
198
|
+
```bash
|
|
199
|
+
analytics-ops portfolio
|
|
200
|
+
analytics-ops portfolio --last 7 --compare
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
Portfolio runs one small dimensionless totals report for each configured
|
|
204
|
+
profile and shows active users, sessions, and key events together. Each
|
|
205
|
+
profile uses its own saved named connection. This is read-only, but each
|
|
206
|
+
property still consumes normal Google Data API quota.
|
|
207
|
+
|
|
208
|
+
Run any built-in or profile-saved report across all properties:
|
|
209
|
+
|
|
210
|
+
```bash
|
|
211
|
+
analytics-ops portfolio report traffic
|
|
212
|
+
analytics-ops portfolio report traffic --csv --concurrency 4
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
One property failing does not discard successful results from the others.
|
|
216
|
+
Concurrency is bounded from 1 through 8 and output order stays stable.
|
|
217
|
+
Profile `labels` are included in JSON, human output, and CSV. Human output
|
|
218
|
+
shows each successful property's table. JSON preserves both successful
|
|
219
|
+
results and isolated errors. CSV is produced only when every property
|
|
220
|
+
succeeds, so an automation cannot mistake a partial portfolio for a complete
|
|
221
|
+
one.
|
|
222
|
+
|
|
223
|
+
For deterministic multi-property checks, see
|
|
224
|
+
[Analytics health](health.md).
|
|
225
|
+
|
|
68
226
|
## Ruby
|
|
69
227
|
|
|
70
228
|
```ruby
|
|
@@ -81,6 +239,10 @@ puts result.metadata
|
|
|
81
239
|
overview = workspace.overview
|
|
82
240
|
puts overview.report("overview_totals").rows
|
|
83
241
|
puts overview.property_quota
|
|
242
|
+
|
|
243
|
+
ranges = AnalyticsOps::Reports::Period.resolve(last_days: 7, compare: true)
|
|
244
|
+
comparison = workspace.report("traffic", date_ranges: ranges)
|
|
245
|
+
puts comparison.rows
|
|
84
246
|
```
|
|
85
247
|
|
|
86
248
|
Custom immutable definition:
|
data/docs/safety.md
CHANGED
|
@@ -5,12 +5,19 @@ Analytics Ops is read-only unless you explicitly apply a saved plan.
|
|
|
5
5
|
## What cannot mutate
|
|
6
6
|
|
|
7
7
|
`properties`, `doctor`, `discover`, `snapshot`, `audit`, `plan`, `verify`,
|
|
8
|
-
`overview`, `
|
|
8
|
+
`overview`, `portfolio`, `health`, `report`, `realtime`, `fields`,
|
|
9
|
+
`compatibility`, `governance`, `changes`, `access`, and every `mcp` tool only
|
|
10
|
+
read Google Analytics APIs. Setup
|
|
9
11
|
validates a service-account key, verifies access, remembers only its path, and
|
|
10
12
|
creates local configuration; it never changes Analytics. Loading the gem,
|
|
11
13
|
constructing a connection, parsing YAML, and booting Rails make no network
|
|
12
14
|
request.
|
|
13
15
|
|
|
16
|
+
Optional `raw` and `funnel` commands submit read-only BigQuery `SELECT` query
|
|
17
|
+
jobs. They cannot change the GA4 export tables, but query jobs can incur cost.
|
|
18
|
+
Every query is fixed, parameterized, dry-run first, and limited by a mandatory
|
|
19
|
+
`maximum_bytes_billed` value.
|
|
20
|
+
|
|
14
21
|
## What apply requires
|
|
15
22
|
|
|
16
23
|
An apply needs all of these:
|
|
@@ -89,11 +96,17 @@ The public schema is [plan-schema-v1.json](plan-schema-v1.json).
|
|
|
89
96
|
## Credential and data boundaries
|
|
90
97
|
|
|
91
98
|
- Credentials never belong in YAML, plans, fixtures, logs, or command output.
|
|
92
|
-
- `~/.config/analytics_ops/connection.json` contains only
|
|
93
|
-
atomic, and uses mode `0600`;
|
|
99
|
+
- `~/.config/analytics_ops/connection.json` contains only named key paths and
|
|
100
|
+
local profile selections, is atomic, and uses mode `0600`; keys are never
|
|
101
|
+
copied.
|
|
102
|
+
- Setup and doctor warn when a key is broadly readable or stored inside a Git
|
|
103
|
+
repository.
|
|
94
104
|
- Ambient Application Default Credentials and API keys are never used.
|
|
95
|
-
-
|
|
96
|
-
|
|
105
|
+
- Normal read-only commands request only `analytics.readonly`. Google requires
|
|
106
|
+
`analytics.edit` to read change history, so `changes` uses that scope without
|
|
107
|
+
exposing any mutation operation. Guarded apply also requests edit scope.
|
|
108
|
+
- Optional BigQuery reports request only `bigquery.readonly` and still require
|
|
109
|
+
narrow Cloud IAM permissions to create a query job and read the dataset.
|
|
97
110
|
- Report rows are never logged automatically.
|
|
98
111
|
- The gem has no telemetry and stores no report results.
|
|
99
112
|
- The gem does not inject browser analytics or manage consent.
|
|
@@ -103,3 +116,22 @@ The public schema is [plan-schema-v1.json](plan-schema-v1.json).
|
|
|
103
116
|
Use a Viewer service account for reports-only installations. If one service
|
|
104
117
|
account has the Editor role, Analytics Ops still requests edit scope only
|
|
105
118
|
inside the reviewed apply path.
|
|
119
|
+
|
|
120
|
+
## AI safety
|
|
121
|
+
|
|
122
|
+
The MCP server is secure by capability, not merely by instructions:
|
|
123
|
+
|
|
124
|
+
- every advertised tool declares `readOnlyHint: true` and
|
|
125
|
+
`destructiveHint: false`
|
|
126
|
+
- no plan, apply, create, update, delete, archive, or credential tool exists
|
|
127
|
+
- starting the MCP process does not load credentials or contact Google
|
|
128
|
+
- tool inputs use strict schemas and bounded report/date choices
|
|
129
|
+
- custom reports are checked against property metadata and compatibility
|
|
130
|
+
- optional BigQuery tools accept fixed names, not arbitrary SQL, and keep the
|
|
131
|
+
configured byte cap
|
|
132
|
+
- expected errors are typed and redacted; unexpected errors are generic
|
|
133
|
+
|
|
134
|
+
An AI provider receives the analytics rows or configuration returned by tools
|
|
135
|
+
you let it call. Connect only an AI account and workspace whose data policy is
|
|
136
|
+
appropriate for that information. The credential file and saved credential
|
|
137
|
+
path are never returned.
|
data/docs/troubleshooting.md
CHANGED
|
@@ -56,13 +56,21 @@ Check that:
|
|
|
56
56
|
- the Analytics Admin and Data APIs are enabled in its Cloud project
|
|
57
57
|
- the service-account email is added in GA4 Access Management
|
|
58
58
|
|
|
59
|
-
The saved
|
|
60
|
-
only
|
|
61
|
-
|
|
59
|
+
The saved connection file is `~/.config/analytics_ops/connection.json`. It
|
|
60
|
+
contains only named key paths and local profile choices. Rerunning setup with
|
|
61
|
+
`--service-account` updates the selected named connection only after API
|
|
62
|
+
verification succeeds.
|
|
62
63
|
|
|
63
64
|
Analytics Ops does not fall back to `gcloud`, browser login, Application
|
|
64
65
|
Default Credentials, `GOOGLE_APPLICATION_CREDENTIALS`, or API keys.
|
|
65
66
|
|
|
67
|
+
If several connections exist and a profile is not associated yet, choose one:
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
analytics-ops connections
|
|
71
|
+
analytics-ops use client_b --connection client_b
|
|
72
|
+
```
|
|
73
|
+
|
|
66
74
|
## Permission failure (exit 77)
|
|
67
75
|
|
|
68
76
|
The service-account identity needs access inside the GA4 property. Cloud IAM
|
|
@@ -91,7 +99,8 @@ snapshot and replan so remote state is reconciled first.
|
|
|
91
99
|
## Drift status 2
|
|
92
100
|
|
|
93
101
|
This is expected automation behavior, not a crash. `audit` and `verify`
|
|
94
|
-
return 2 when managed state differs or a drift finding remains.
|
|
102
|
+
return 2 when managed state differs or a drift finding remains. `health` and
|
|
103
|
+
`governance` also return 2 when they find a configured issue.
|
|
95
104
|
|
|
96
105
|
## Stale plan (exit 79)
|
|
97
106
|
|
|
@@ -107,6 +116,13 @@ generate a new plan. Do not rerun the old plan.
|
|
|
107
116
|
|
|
108
117
|
## A custom report fails
|
|
109
118
|
|
|
119
|
+
Find the exact property-aware names and check compatibility:
|
|
120
|
+
|
|
121
|
+
```bash
|
|
122
|
+
analytics-ops fields event
|
|
123
|
+
analytics-ops compatibility REPORT_NAME
|
|
124
|
+
```
|
|
125
|
+
|
|
110
126
|
Confirm every requested dimension and metric is compatible and registered.
|
|
111
127
|
Event-scoped custom fields use names such as
|
|
112
128
|
`customEvent:calculator_slug`. Standard reports require a date range;
|
|
@@ -116,15 +132,73 @@ Google thresholding, sampling, consent coverage, cardinality, and processing
|
|
|
116
132
|
delay can explain an empty or lower-than-expected result. Inspect
|
|
117
133
|
`result.metadata` in JSON output.
|
|
118
134
|
|
|
119
|
-
|
|
135
|
+
Date shortcuts are strict:
|
|
136
|
+
|
|
137
|
+
- use `--last DAYS`, or use `--from YYYY-MM-DD --to YYYY-MM-DD`
|
|
138
|
+
- do not combine the two forms
|
|
139
|
+
- use them only with date-aware read commands
|
|
140
|
+
- `--compare` uses the immediately preceding period of the same length
|
|
141
|
+
|
|
142
|
+
## Governance or access is unavailable
|
|
143
|
+
|
|
144
|
+
Governance uses Alpha Admin methods. Confirm the installed client with
|
|
145
|
+
`analytics-ops doctor` and remember that Google can change Alpha behavior.
|
|
146
|
+
|
|
147
|
+
The aggregate `access` command requires the service-account identity to be a
|
|
148
|
+
GA4 Administrator. Normal Viewer/Editor reporting still works when this
|
|
149
|
+
special command is denied. The `changes` command is read-only but Google
|
|
150
|
+
requires the Analytics edit scope for it.
|
|
151
|
+
|
|
152
|
+
## A BigQuery report or funnel fails
|
|
153
|
+
|
|
154
|
+
Normal Analytics reports do not need BigQuery. For the optional feature,
|
|
155
|
+
confirm:
|
|
120
156
|
|
|
121
|
-
|
|
122
|
-
|
|
157
|
+
- `google-cloud-bigquery ~> 1.64` is in the application's bundle
|
|
158
|
+
- the GA4 BigQuery export has created `events_YYYYMMDD` tables
|
|
159
|
+
- the configured project, dataset, and location are correct
|
|
160
|
+
- the service account can create query jobs and read the dataset
|
|
161
|
+
- the BigQuery API is enabled
|
|
162
|
+
- `maximum_bytes_billed` is high enough for the selected period
|
|
163
|
+
|
|
164
|
+
A quota error before execution can mean the dry-run estimate exceeded your
|
|
165
|
+
configured cap. Shorten the date range or deliberately review the cap; do not
|
|
166
|
+
remove it. See [BigQuery reports and funnels](bigquery.md).
|
|
167
|
+
|
|
168
|
+
## Rails setup or task cannot find a profile
|
|
169
|
+
|
|
170
|
+
The generator and CLI both default to `production`. It is safe to run the
|
|
171
|
+
generator before setup: setup fills its untouched property placeholder. It is
|
|
172
|
+
also safe to run setup first: the generator preserves that file.
|
|
173
|
+
|
|
174
|
+
Rails tasks use the locally selected profile. Inspect or change it:
|
|
175
|
+
|
|
176
|
+
```bash
|
|
177
|
+
bundle exec analytics-ops profiles
|
|
178
|
+
bundle exec analytics-ops use production
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
For one explicit task override:
|
|
123
182
|
|
|
124
183
|
```bash
|
|
125
184
|
ANALYTICS_OPS_PROFILE=production bin/rake analytics:doctor
|
|
126
185
|
```
|
|
127
186
|
|
|
187
|
+
## ChatGPT or Claude cannot see Analytics Ops
|
|
188
|
+
|
|
189
|
+
First prove the local command is healthy:
|
|
190
|
+
|
|
191
|
+
```bash
|
|
192
|
+
analytics-ops doctor
|
|
193
|
+
analytics-ops reports
|
|
194
|
+
analytics-ops mcp --config /absolute/path/to/config/analytics_ops.yml
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
The second command waits silently for MCP protocol input; press Ctrl-C when
|
|
198
|
+
testing it directly. Use an absolute config path and, for Bundler applications,
|
|
199
|
+
the absolute generated `bin/analytics-ops` path. Restart the AI client after
|
|
200
|
+
adding the server. See [ChatGPT, Codex, and Claude](ai-connections.md).
|
|
201
|
+
|
|
128
202
|
## Report a problem safely
|
|
129
203
|
|
|
130
204
|
Include the gem version, Ruby version, command name, exit status, and a minimal
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "date"
|
|
4
|
+
|
|
5
|
+
module AnalyticsOps
|
|
6
|
+
# Optional, read-only GA4 BigQuery export support.
|
|
7
|
+
module BigQuery
|
|
8
|
+
RECIPE_NAMES = %w[event_counts daily_events page_views event_sample].freeze
|
|
9
|
+
|
|
10
|
+
# Immutable, bounded request for one fixed BigQuery report recipe.
|
|
11
|
+
class Definition
|
|
12
|
+
attr_reader :name, :start_suffix, :end_suffix, :limit
|
|
13
|
+
|
|
14
|
+
def initialize(name:, start_date:, end_date:, limit: 1_000)
|
|
15
|
+
@name = validate_name(name)
|
|
16
|
+
@start_suffix = suffix(start_date, "start_date")
|
|
17
|
+
@end_suffix = suffix(end_date, "end_date")
|
|
18
|
+
raise InvalidRequestError, "BigQuery start_date must not be after end_date" if @start_suffix > @end_suffix
|
|
19
|
+
|
|
20
|
+
@limit = bounded_integer(limit, "limit", 1, 10_000)
|
|
21
|
+
freeze
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def self.from_date_ranges(name, date_ranges, limit: 1_000, today: Date.today)
|
|
25
|
+
unless date_ranges.is_a?(Array) && date_ranges.length == 1
|
|
26
|
+
raise InvalidRequestError, "BigQuery reports require exactly one date range"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
range = date_ranges.first
|
|
30
|
+
new(
|
|
31
|
+
name:,
|
|
32
|
+
start_date: resolve_date(range.fetch("start_date"), today:),
|
|
33
|
+
end_date: resolve_date(range.fetch("end_date"), today:),
|
|
34
|
+
limit:
|
|
35
|
+
)
|
|
36
|
+
rescue KeyError
|
|
37
|
+
raise InvalidRequestError, "BigQuery report date range is invalid"
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def to_h
|
|
41
|
+
{ "name" => name, "start_suffix" => start_suffix, "end_suffix" => end_suffix, "limit" => limit }
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def self.resolve_date(value, today:)
|
|
45
|
+
return today if value == "today"
|
|
46
|
+
return today - 1 if value == "yesterday"
|
|
47
|
+
|
|
48
|
+
relative = /\A(\d{1,4})daysAgo\z/.match(value.to_s)
|
|
49
|
+
return today - relative[1].to_i if relative
|
|
50
|
+
|
|
51
|
+
Date.iso8601(value)
|
|
52
|
+
rescue Date::Error
|
|
53
|
+
raise InvalidRequestError, "BigQuery report date is invalid"
|
|
54
|
+
end
|
|
55
|
+
private_class_method :resolve_date
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
def validate_name(value)
|
|
60
|
+
name = value.to_s
|
|
61
|
+
return name.freeze if RECIPE_NAMES.include?(name)
|
|
62
|
+
|
|
63
|
+
raise InvalidRequestError, "Unknown BigQuery report #{value.inspect}; available: #{RECIPE_NAMES.join(", ")}"
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def suffix(value, label)
|
|
67
|
+
date = value.is_a?(Date) ? value : Date.iso8601(value.to_s)
|
|
68
|
+
date.strftime("%Y%m%d").freeze
|
|
69
|
+
rescue Date::Error
|
|
70
|
+
raise InvalidRequestError, "BigQuery #{label} is invalid"
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def bounded_integer(value, label, minimum, maximum)
|
|
74
|
+
return value if value.is_a?(Integer) && value.between?(minimum, maximum)
|
|
75
|
+
|
|
76
|
+
raise InvalidRequestError, "BigQuery #{label} must be between #{minimum} and #{maximum}"
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Immutable, bounded result returned by the optional BigQuery adapter.
|
|
81
|
+
class Result
|
|
82
|
+
NAME = /\A[A-Za-z][A-Za-z0-9_]{0,71}\z/
|
|
83
|
+
HEADER = /\A[A-Za-z_][A-Za-z0-9_]{0,127}\z/
|
|
84
|
+
MAX_ROWS = 10_000
|
|
85
|
+
MAX_CELL_BYTES = 1_048_576
|
|
86
|
+
|
|
87
|
+
attr_reader :name, :headers, :rows, :bytes_processed, :maximum_bytes_billed
|
|
88
|
+
|
|
89
|
+
def initialize(name:, headers:, rows:, bytes_processed:, maximum_bytes_billed:)
|
|
90
|
+
validate_name!(name)
|
|
91
|
+
validate_headers!(headers)
|
|
92
|
+
normalized_rows = validate_rows(rows, headers)
|
|
93
|
+
validate_byte_counts!(bytes_processed, maximum_bytes_billed)
|
|
94
|
+
|
|
95
|
+
@name = name.dup.freeze
|
|
96
|
+
@headers = Canonical.immutable(headers)
|
|
97
|
+
@rows = Canonical.immutable(normalized_rows)
|
|
98
|
+
@bytes_processed = bytes_processed
|
|
99
|
+
@maximum_bytes_billed = maximum_bytes_billed
|
|
100
|
+
freeze
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def to_h
|
|
104
|
+
{
|
|
105
|
+
"experimental" => true,
|
|
106
|
+
"name" => name,
|
|
107
|
+
"headers" => headers,
|
|
108
|
+
"rows" => rows,
|
|
109
|
+
"row_count" => rows.length,
|
|
110
|
+
"bytes_processed" => bytes_processed,
|
|
111
|
+
"maximum_bytes_billed" => maximum_bytes_billed
|
|
112
|
+
}
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
private
|
|
116
|
+
|
|
117
|
+
def validate_name!(value)
|
|
118
|
+
return if value.is_a?(String) && NAME.match?(value)
|
|
119
|
+
|
|
120
|
+
raise RemoteError, "BigQuery result name is invalid"
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def validate_headers!(values)
|
|
124
|
+
valid = values.is_a?(Array) &&
|
|
125
|
+
values.length <= 64 &&
|
|
126
|
+
values.all? { |header| header.is_a?(String) && HEADER.match?(header) } &&
|
|
127
|
+
values.uniq.length == values.length
|
|
128
|
+
raise RemoteError, "BigQuery result headers are invalid" unless valid
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def validate_rows(values, headers)
|
|
132
|
+
raise RemoteError, "BigQuery result rows are invalid" unless values.is_a?(Array) && values.length <= MAX_ROWS
|
|
133
|
+
|
|
134
|
+
values.map { |row| validate_row(row, headers) }
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def validate_byte_counts!(processed, maximum)
|
|
138
|
+
valid = processed.is_a?(Integer) &&
|
|
139
|
+
!processed.negative? &&
|
|
140
|
+
maximum.is_a?(Integer) &&
|
|
141
|
+
maximum.positive? &&
|
|
142
|
+
processed <= maximum
|
|
143
|
+
raise RemoteError, "BigQuery result byte counts are invalid" unless valid
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def validate_row(row, headers)
|
|
147
|
+
unless row.is_a?(Hash) && row.keys.all?(String) && row.keys.sort == headers.sort
|
|
148
|
+
raise RemoteError, "BigQuery result row does not match its headers"
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
row.to_h do |header, value|
|
|
152
|
+
[header, validate_cell(value)]
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def validate_cell(value)
|
|
157
|
+
case value
|
|
158
|
+
when String
|
|
159
|
+
unless value.valid_encoding? && value.bytesize <= MAX_CELL_BYTES
|
|
160
|
+
raise RemoteError, "BigQuery result contains an invalid string"
|
|
161
|
+
end
|
|
162
|
+
when Float
|
|
163
|
+
raise RemoteError, "BigQuery result contains a non-finite number" unless value.finite?
|
|
164
|
+
when Integer, TrueClass, FalseClass, NilClass
|
|
165
|
+
nil
|
|
166
|
+
else
|
|
167
|
+
raise RemoteError, "BigQuery result contains an unsupported value"
|
|
168
|
+
end
|
|
169
|
+
value
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
end
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AnalyticsOps
|
|
4
|
+
# Command-line consumer of the public Analytics Ops API.
|
|
5
|
+
class CLI
|
|
6
|
+
# Implements local setup, selection, portfolio, and AI commands.
|
|
7
|
+
module LocalCommands
|
|
8
|
+
private
|
|
9
|
+
|
|
10
|
+
def dispatch_local(command)
|
|
11
|
+
case command
|
|
12
|
+
when "connections"
|
|
13
|
+
presenter.render_connections(service_account_store.summaries)
|
|
14
|
+
when "profiles"
|
|
15
|
+
render_profiles
|
|
16
|
+
when "use"
|
|
17
|
+
use_profile
|
|
18
|
+
when "mcp"
|
|
19
|
+
start_mcp
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def render_profiles
|
|
24
|
+
config = @options.fetch(:config)
|
|
25
|
+
document = Configuration.load(config)
|
|
26
|
+
selected = service_account_store.selection(config:)
|
|
27
|
+
selected_profile = selected&.fetch("profile") || @options.fetch(:profile)
|
|
28
|
+
profiles = document.profiles.sort.map do |name, desired_state|
|
|
29
|
+
{
|
|
30
|
+
"name" => name,
|
|
31
|
+
"property_id" => desired_state.property_id,
|
|
32
|
+
"connection" => service_account_store.profile_connection(config:, profile: name),
|
|
33
|
+
"selected" => selected_profile == name
|
|
34
|
+
}
|
|
35
|
+
end
|
|
36
|
+
presenter.render_profiles(profiles)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def use_profile
|
|
40
|
+
profile = required_use_profile!
|
|
41
|
+
config = @options.fetch(:config)
|
|
42
|
+
Configuration.load(config).profile(profile)
|
|
43
|
+
selection = service_account_store.select(
|
|
44
|
+
config:,
|
|
45
|
+
profile:,
|
|
46
|
+
connection: @options[:connection]
|
|
47
|
+
)
|
|
48
|
+
presenter.render_selection(selection)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def start_mcp
|
|
52
|
+
@mcp_server_loader.call(
|
|
53
|
+
config: @options.fetch(:config),
|
|
54
|
+
profile: resolved_profile,
|
|
55
|
+
connection: @options[:connection],
|
|
56
|
+
store: service_account_store,
|
|
57
|
+
workspace_loader: @workspace_loader,
|
|
58
|
+
connection_loader: @connection_loader,
|
|
59
|
+
service_account_loader: @service_account_loader,
|
|
60
|
+
portfolio_loader: @portfolio_loader,
|
|
61
|
+
transport: @options.fetch(:transport),
|
|
62
|
+
timeout: @options[:timeout],
|
|
63
|
+
logger: operation_logger
|
|
64
|
+
).start
|
|
65
|
+
SUCCESS
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def required_use_profile!
|
|
69
|
+
raise OptionParser::MissingArgument, "use requires exactly one PROFILE" unless @arguments.length == 1
|
|
70
|
+
|
|
71
|
+
profile = @arguments.first
|
|
72
|
+
raise OptionParser::InvalidArgument, "use requires a valid profile name" unless Options::PROFILE.match?(profile)
|
|
73
|
+
|
|
74
|
+
profile
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
include LocalCommands
|
|
79
|
+
end
|
|
80
|
+
end
|