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
@@ -6,22 +6,33 @@ module AnalyticsOps
6
6
  class Options
7
7
  DEFAULTS = {
8
8
  config: "config/analytics_ops.yml",
9
+ config_explicit: false,
9
10
  profile: "production",
11
+ profile_explicit: false,
10
12
  format: "human",
11
13
  log_level: "warn",
14
+ log_level_explicit: false,
12
15
  transport: :grpc,
16
+ transport_explicit: false,
17
+ timeout_explicit: false,
13
18
  yes: false,
14
- noninteractive: false
19
+ noninteractive: false,
20
+ compare: false,
21
+ dimensions: [].freeze,
22
+ metrics: [].freeze,
23
+ filters: [].freeze,
24
+ all_rows: false
15
25
  }.freeze
16
26
  SETUP_OPTIONS = %i[property service_account].freeze
17
27
  PROPERTY_ID = /\A\d{1,50}\z/
18
28
  PROFILE = /\A[A-Za-z][A-Za-z0-9_]{0,63}\z/
29
+ CONNECTION = /\A[A-Za-z][A-Za-z0-9_-]{0,63}\z/
19
30
 
20
31
  attr_reader :values
21
32
 
22
33
  def initialize(arguments)
23
34
  @arguments = arguments
24
- @values = DEFAULTS.dup
35
+ @values = DEFAULTS.transform_values { |value| value.is_a?(Array) ? value.dup : value }
25
36
  @explicit_format = nil
26
37
  end
27
38
 
@@ -38,15 +49,25 @@ module AnalyticsOps
38
49
  add_file_options(options)
39
50
  add_format_options(options)
40
51
  add_setup_options(options)
52
+ add_report_options(options)
41
53
  add_client_options(options)
42
54
  add_execution_options(options)
43
55
  end
44
56
  end
45
57
 
46
58
  def add_file_options(options)
47
- options.on("-c", "--config PATH", "Configuration path") { |path| @values[:config] = path }
48
- options.on("-p", "--profile NAME", "Configuration profile") { |name| @values[:profile] = name }
49
- options.on("-o", "--output PATH", "Write a generated plan to PATH") { |path| @values[:output] = path }
59
+ options.on("-c", "--config PATH", "Configuration path") do |path|
60
+ @values[:config] = path
61
+ @values[:config_explicit] = true
62
+ end
63
+ options.on("-p", "--profile NAME", "Configuration profile") do |name|
64
+ @values[:profile] = name
65
+ @values[:profile_explicit] = true
66
+ end
67
+ options.on("--connection NAME", "Saved Google connection") { |name| @values[:connection] = name }
68
+ options.on("-o", "--output PATH", "Write a plan or bounded CSV export to PATH") do |path|
69
+ @values[:output] = path
70
+ end
50
71
  end
51
72
 
52
73
  def add_format_options(options)
@@ -73,12 +94,46 @@ module AnalyticsOps
73
94
  end
74
95
  end
75
96
 
97
+ def add_report_options(options)
98
+ options.on("--last DAYS", Integer, "Use the previous number of complete days") do |days|
99
+ @values[:last_days] = days
100
+ end
101
+ options.on("--from DATE", "Report start date (YYYY-MM-DD)") { |date| @values[:start_date] = date }
102
+ options.on("--to DATE", "Report end date (YYYY-MM-DD)") { |date| @values[:end_date] = date }
103
+ options.on("--compare", "Also include the equally long preceding period") { @values[:compare] = true }
104
+ options.on("--dimension NAME", "Dimension for `report custom` (repeatable)") do |name|
105
+ @values.fetch(:dimensions) << name
106
+ end
107
+ options.on("--metric NAME", "Metric for `report custom` (repeatable)") do |name|
108
+ @values.fetch(:metrics) << name
109
+ end
110
+ options.on("--where FIELD=VALUE", "Exact dimension filter for `report custom` (repeatable)") do |value|
111
+ @values.fetch(:filters) << value
112
+ end
113
+ options.on("--limit ROWS", Integer, "Maximum rows in one report request") { |value| @values[:limit] = value }
114
+ options.on("--all", "Export every available row, within --max-rows") { @values[:all_rows] = true }
115
+ options.on("--max-rows ROWS", Integer, "Hard limit for --all (default 1000000)") do |value|
116
+ @values[:max_rows] = value
117
+ end
118
+ options.on("--page-size ROWS", Integer, "Rows per --all API request (default 100000)") do |value|
119
+ @values[:page_size] = value
120
+ end
121
+ options.on("--kind KIND", %w[dimension metric], "Limit `fields` to one field kind") do |value|
122
+ @values[:field_kind] = value
123
+ end
124
+ options.on("--concurrency COUNT", Integer, "Portfolio requests at once (1-8; default 4)") do |value|
125
+ @values[:concurrency] = value
126
+ end
127
+ end
128
+
76
129
  def add_client_options(options)
77
130
  options.on("--log-level LEVEL", CLI::LOG_LEVELS, CLI::LOG_LEVELS.join(", ")) do |level|
78
131
  @values[:log_level] = level
132
+ @values[:log_level_explicit] = true
79
133
  end
80
134
  options.on("--transport TRANSPORT", %w[grpc rest], "grpc or rest") do |transport|
81
135
  @values[:transport] = transport.to_sym
136
+ @values[:transport_explicit] = true
82
137
  end
83
138
  options.on("--timeout SECONDS", Float, "Google API timeout") do |seconds|
84
139
  unless seconds.finite? && seconds.positive?
@@ -86,6 +141,7 @@ module AnalyticsOps
86
141
  end
87
142
 
88
143
  @values[:timeout] = seconds
144
+ @values[:timeout_explicit] = true
89
145
  end
90
146
  end
91
147
 
@@ -97,7 +153,16 @@ module AnalyticsOps
97
153
  def validate!(command)
98
154
  validate_arguments!(command)
99
155
  validate_scoped_options!(command)
156
+ validate_local_options!(command)
157
+ validate_connection!
158
+ validate_report_period!(command)
100
159
  validate_setup! if command == "setup"
160
+ validate_apply! if command == "apply"
161
+ validate_mcp! if command == "mcp"
162
+ validate_custom_report!(command)
163
+ validate_export!(command)
164
+ validate_field_options!(command)
165
+ validate_portfolio_options!(command)
101
166
  validate_format!(command)
102
167
  end
103
168
 
@@ -108,16 +173,212 @@ module AnalyticsOps
108
173
  end
109
174
 
110
175
  def validate_scoped_options!(command)
111
- raise OptionParser::InvalidArgument, "--output is only valid with plan" if @values[:output] && command != "plan"
176
+ validate_plan_scope!(command)
177
+ validate_selection_scope!(command)
178
+ validate_execution_scope!(command)
179
+ validate_setup_scope!(command)
180
+ end
181
+
182
+ def validate_local_options!(command)
183
+ if %w[connections schema].include?(command) && @values.fetch(:config_explicit)
184
+ raise OptionParser::InvalidArgument, "--config is not valid with #{command}"
185
+ end
186
+ return unless %w[connections profiles schema use].include?(command)
187
+ return unless @values.fetch(:log_level_explicit) ||
188
+ @values.fetch(:transport_explicit) ||
189
+ @values.fetch(:timeout_explicit)
190
+
191
+ raise OptionParser::InvalidArgument,
192
+ "--log-level, --transport, and --timeout are not valid with #{command}"
193
+ end
194
+
195
+ def validate_plan_scope!(command)
196
+ if @values[:output] && !%w[plan report].include?(command)
197
+ raise OptionParser::InvalidArgument, "--output is only valid with plan or report --all"
198
+ end
112
199
  raise OptionParser::InvalidArgument, "--yes is only valid with apply" if @values[:yes] && command != "apply"
113
- if @values[:noninteractive] && !%w[apply setup].include?(command)
114
- raise OptionParser::InvalidArgument, "--non-interactive is only valid with apply or setup"
200
+ end
201
+
202
+ def validate_selection_scope!(command)
203
+ if @values[:connection] && %w[connections portfolio profiles schema].include?(command)
204
+ raise OptionParser::InvalidArgument, "--connection is not valid with #{command}"
115
205
  end
206
+ return unless @values[:profile_explicit] && %w[connections portfolio profiles schema use].include?(command)
207
+
208
+ raise OptionParser::InvalidArgument, "--profile is not valid with #{command}"
209
+ end
210
+
211
+ def validate_execution_scope!(command)
212
+ return unless @values[:noninteractive] && !%w[apply setup].include?(command)
213
+
214
+ raise OptionParser::InvalidArgument, "--non-interactive is only valid with apply or setup"
215
+ end
216
+
217
+ def validate_setup_scope!(command)
116
218
  return if SETUP_OPTIONS.none? { |name| @values[name] } || command == "setup"
117
219
 
118
220
  raise OptionParser::InvalidArgument, "--property and --service-account are setup-only"
119
221
  end
120
222
 
223
+ def validate_connection!
224
+ return unless @values[:connection] && !CONNECTION.match?(@values.fetch(:connection))
225
+
226
+ raise OptionParser::InvalidArgument,
227
+ "--connection must start with a letter and use only letters, numbers, hyphens, or underscores"
228
+ end
229
+
230
+ def validate_report_period!(command)
231
+ requested = @values[:last_days] || @values[:start_date] || @values[:end_date] || @values[:compare]
232
+ return unless requested
233
+ unless %w[overview portfolio report health raw funnel].include?(command)
234
+ raise OptionParser::InvalidArgument,
235
+ "--last, --from, --to, and --compare are only valid with date-aware read commands"
236
+ end
237
+ if %w[raw funnel].include?(command) && @values.fetch(:compare)
238
+ raise OptionParser::InvalidArgument, "#{command} does not support --compare"
239
+ end
240
+
241
+ Reports::Period.resolve(
242
+ last_days: @values[:last_days],
243
+ start_date: @values[:start_date],
244
+ end_date: @values[:end_date],
245
+ compare: @values.fetch(:compare)
246
+ )
247
+ rescue InvalidRequestError => error
248
+ raise OptionParser::InvalidArgument, error.message
249
+ end
250
+
251
+ def validate_custom_report!(command)
252
+ return validate_raw_report_options! if command == "raw"
253
+
254
+ custom = command == "report" && @arguments.first == "custom"
255
+ selected = custom_report_options_selected?
256
+ require_custom_metrics! if custom
257
+ return unless selected
258
+
259
+ validate_custom_report_scope!(command)
260
+ require_custom_metrics!
261
+ validate_positive_bound!(:limit, Reports::Definition::MAX_STANDARD_LIMIT)
262
+ filters = parsed_custom_filters
263
+ @values[:custom_definition] = Reports::Definition.new(
264
+ name: "custom_report",
265
+ kind: "standard",
266
+ dimensions: @values.fetch(:dimensions),
267
+ metrics: @values.fetch(:metrics),
268
+ date_ranges: custom_report_date_ranges,
269
+ dimension_filter: combined_filter(filters),
270
+ limit: @values.fetch(:limit, 100)
271
+ )
272
+ rescue InvalidRequestError => error
273
+ raise OptionParser::InvalidArgument, error.message
274
+ end
275
+
276
+ def validate_raw_report_options!
277
+ if @values.fetch(:dimensions).any? || @values.fetch(:metrics).any? || @values.fetch(:filters).any?
278
+ raise OptionParser::InvalidArgument, "--dimension, --metric, and --where are not valid with raw"
279
+ end
280
+
281
+ validate_positive_bound!(:limit, 10_000)
282
+ end
283
+
284
+ def custom_report_options_selected?
285
+ @values.fetch(:dimensions).any? ||
286
+ @values.fetch(:metrics).any? ||
287
+ @values.fetch(:filters).any? ||
288
+ @values[:limit]
289
+ end
290
+
291
+ def validate_custom_report_scope!(command)
292
+ unless command == "report"
293
+ raise OptionParser::InvalidArgument, "custom report options are only valid with report"
294
+ end
295
+ return if @arguments.first == "custom"
296
+
297
+ raise OptionParser::InvalidArgument, "--dimension, --metric, --where, and --limit require `report custom`"
298
+ end
299
+
300
+ def require_custom_metrics!
301
+ return unless @values.fetch(:metrics).empty?
302
+
303
+ raise OptionParser::MissingArgument, "`report custom` requires at least one --metric"
304
+ end
305
+
306
+ def parsed_custom_filters
307
+ @values.fetch(:filters).map do |filter|
308
+ field, value = filter.to_s.split("=", 2)
309
+ unless field && value && !field.empty? && !value.empty?
310
+ raise OptionParser::InvalidArgument, "--where must use FIELD=VALUE"
311
+ end
312
+
313
+ { "field" => field, "match_type" => "exact", "value" => value }
314
+ end
315
+ end
316
+
317
+ def combined_filter(filters)
318
+ filters.length > 1 ? { "and" => filters } : filters.first
319
+ end
320
+
321
+ def custom_report_date_ranges
322
+ Reports::Period.resolve(
323
+ last_days: @values[:last_days],
324
+ start_date: @values[:start_date],
325
+ end_date: @values[:end_date],
326
+ compare: @values.fetch(:compare)
327
+ ) || Reports::Catalog::STANDARD_DATE_RANGE
328
+ end
329
+
330
+ def validate_export!(command)
331
+ validate_export_scope!(command)
332
+ validate_export_dependencies!(command)
333
+ validate_positive_bound!(:max_rows, Workspace::MAX_EXPORT_ROWS)
334
+ validate_positive_bound!(:page_size, Reports::Definition::MAX_STANDARD_LIMIT)
335
+ end
336
+
337
+ def validate_export_scope!(command)
338
+ requested = @values.fetch(:all_rows) || @values[:max_rows] || @values[:page_size]
339
+ return unless requested && command != "report"
340
+
341
+ raise OptionParser::InvalidArgument, "--all, --max-rows, and --page-size are report-only"
342
+ end
343
+
344
+ def validate_export_dependencies!(command)
345
+ if @values.fetch(:all_rows)
346
+ raise OptionParser::MissingArgument, "--all requires --output PATH" unless @values[:output]
347
+ elsif command == "report" && @values[:output]
348
+ raise OptionParser::InvalidArgument, "report --output requires --all"
349
+ end
350
+ return unless (@values[:max_rows] || @values[:page_size]) && !@values.fetch(:all_rows)
351
+
352
+ raise OptionParser::InvalidArgument, "--max-rows and --page-size require --all"
353
+ end
354
+
355
+ def validate_positive_bound!(name, maximum)
356
+ value = @values[name]
357
+ return unless value
358
+ return if value.is_a?(Integer) && value.between?(1, maximum)
359
+
360
+ raise OptionParser::InvalidArgument, "--#{name.to_s.tr("_", "-")} must be between 1 and #{maximum}"
361
+ end
362
+
363
+ def validate_field_options!(command)
364
+ return unless @values[:field_kind] && command != "fields"
365
+
366
+ raise OptionParser::InvalidArgument, "--kind is only valid with fields"
367
+ end
368
+
369
+ def validate_portfolio_options!(command)
370
+ if @values[:concurrency] && command != "portfolio"
371
+ raise OptionParser::InvalidArgument, "--concurrency is only valid with portfolio"
372
+ end
373
+ if @values[:concurrency] && !@values.fetch(:concurrency).between?(1, 8)
374
+ raise OptionParser::InvalidArgument, "--concurrency must be between 1 and 8"
375
+ end
376
+ return unless command == "portfolio" && @values.fetch(:format) == "csv"
377
+ return if @arguments.first == "report" && @arguments.length == 2
378
+
379
+ raise OptionParser::InvalidArgument, "CSV portfolio output requires `portfolio report NAME`"
380
+ end
381
+
121
382
  def validate_setup!
122
383
  validate_setup_property!
123
384
  raise OptionParser::InvalidArgument, "--profile is invalid" unless PROFILE.match?(@values.fetch(:profile))
@@ -147,8 +408,20 @@ module AnalyticsOps
147
408
  raise OptionParser::MissingArgument, "JSON setup requires --non-interactive --property ID"
148
409
  end
149
410
 
411
+ def validate_apply!
412
+ return unless @values.fetch(:format) == "json" && (!@values[:noninteractive] || !@values[:yes])
413
+
414
+ raise OptionParser::MissingArgument, "JSON apply requires --non-interactive --yes"
415
+ end
416
+
417
+ def validate_mcp!
418
+ return if @values.fetch(:format) == "human"
419
+
420
+ raise OptionParser::InvalidArgument, "mcp uses the MCP protocol and does not accept output formats"
421
+ end
422
+
150
423
  def validate_format!(command)
151
- return unless @values.fetch(:format) == "csv" && !%w[report realtime].include?(command)
424
+ return unless @values.fetch(:format) == "csv" && !%w[report realtime portfolio raw funnel].include?(command)
152
425
 
153
426
  raise OptionParser::InvalidArgument, "CSV output is only valid for report results"
154
427
  end