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.
Files changed (72) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +108 -0
  3. data/CONTRIBUTING.md +1 -0
  4. data/README.md +141 -13
  5. data/SECURITY.md +11 -2
  6. data/docs/ai-connections.md +192 -0
  7. data/docs/api-support-matrix.md +25 -8
  8. data/docs/architecture.md +78 -17
  9. data/docs/authentication.md +53 -3
  10. data/docs/bigquery.md +128 -0
  11. data/docs/commands.md +133 -12
  12. data/docs/configuration-schema-v1.json +608 -50
  13. data/docs/configuration.md +118 -5
  14. data/docs/google-client-compatibility.md +21 -1
  15. data/docs/governance.md +93 -0
  16. data/docs/health.md +82 -0
  17. data/docs/live-smoke-test.md +13 -10
  18. data/docs/rails.md +25 -9
  19. data/docs/reports.md +165 -3
  20. data/docs/safety.md +37 -5
  21. data/docs/troubleshooting.md +81 -7
  22. data/lib/analytics_ops/bigquery/definition.rb +173 -0
  23. data/lib/analytics_ops/bigquery.rb +4 -0
  24. data/lib/analytics_ops/cli/local_commands.rb +80 -0
  25. data/lib/analytics_ops/cli/options.rb +282 -9
  26. data/lib/analytics_ops/cli/presenter.rb +263 -28
  27. data/lib/analytics_ops/cli/presenter_csv.rb +92 -0
  28. data/lib/analytics_ops/cli/reporting_commands.rb +129 -0
  29. data/lib/analytics_ops/cli/setup_commands.rb +91 -0
  30. data/lib/analytics_ops/cli.rb +121 -44
  31. data/lib/analytics_ops/clients/admin.rb +28 -2
  32. data/lib/analytics_ops/clients/admin_governance.rb +95 -0
  33. data/lib/analytics_ops/clients/admin_governance_normalization.rb +90 -0
  34. data/lib/analytics_ops/clients/admin_resource_normalization.rb +61 -0
  35. data/lib/analytics_ops/clients/bigquery.rb +313 -0
  36. data/lib/analytics_ops/clients/data.rb +147 -54
  37. data/lib/analytics_ops/clients/data_result_normalization.rb +55 -0
  38. data/lib/analytics_ops/clients/error_translation.rb +13 -1
  39. data/lib/analytics_ops/configuration/schema.rb +175 -1
  40. data/lib/analytics_ops/configuration/validator.rb +45 -7
  41. data/lib/analytics_ops/configuration/validator_experimental.rb +136 -0
  42. data/lib/analytics_ops/configuration/validator_reporting.rb +93 -0
  43. data/lib/analytics_ops/configuration/writer.rb +112 -7
  44. data/lib/analytics_ops/desired_state.rb +28 -3
  45. data/lib/analytics_ops/errors.rb +33 -1
  46. data/lib/analytics_ops/funnels.rb +58 -0
  47. data/lib/analytics_ops/governance.rb +363 -0
  48. data/lib/analytics_ops/health.rb +310 -0
  49. data/lib/analytics_ops/mcp_server/custom_report_tool.rb +101 -0
  50. data/lib/analytics_ops/mcp_server/discovery_tools.rb +79 -0
  51. data/lib/analytics_ops/mcp_server/experimental_tools.rb +89 -0
  52. data/lib/analytics_ops/mcp_server/health_tools.rb +80 -0
  53. data/lib/analytics_ops/mcp_server/overview_tools.rb +49 -0
  54. data/lib/analytics_ops/mcp_server/standard_report_tool.rb +53 -0
  55. data/lib/analytics_ops/mcp_server.rb +402 -0
  56. data/lib/analytics_ops/portfolio.rb +216 -0
  57. data/lib/analytics_ops/rails/railtie.rb +19 -8
  58. data/lib/analytics_ops/redaction.rb +10 -6
  59. data/lib/analytics_ops/reports/csv_export.rb +91 -0
  60. data/lib/analytics_ops/reports/definition.rb +152 -14
  61. data/lib/analytics_ops/reports/metadata.rb +158 -0
  62. data/lib/analytics_ops/reports/period.rb +104 -0
  63. data/lib/analytics_ops/reports/result.rb +8 -3
  64. data/lib/analytics_ops/reports.rb +3 -0
  65. data/lib/analytics_ops/service_account.rb +321 -29
  66. data/lib/analytics_ops/setup.rb +31 -7
  67. data/lib/analytics_ops/version.rb +1 -1
  68. data/lib/analytics_ops/workspace.rb +222 -10
  69. data/lib/analytics_ops.rb +7 -2
  70. data/lib/generators/analytics_ops/templates/analytics_ops.yml +1 -1
  71. data/sig/analytics_ops.rbs +494 -9
  72. 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
- CSV is accepted only for `report` and `realtime`. The CSV renderer protects
42
- headers and cells whose first meaningful character is `=`, `+`, `-`, or `@`,
43
- including formula text hidden behind whitespace or control characters.
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`, `report`, and `realtime` only read Google Analytics APIs. Setup
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 a key path, is
93
- atomic, and uses mode `0600`; the key is never copied.
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
- - Read-only commands request only `analytics.readonly`; guarded apply also
96
- requests `analytics.edit`.
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.
@@ -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 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.
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
- ## Rails task cannot find a profile
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
- Rails tasks default to `Rails.env`. The generator creates a
122
- `development` profile. Add the intended profile or override it:
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,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "bigquery/definition"
4
+ require_relative "clients/bigquery"
@@ -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