analytics_ops 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +76 -0
  3. data/CONTRIBUTING.md +2 -1
  4. data/README.md +232 -41
  5. data/SECURITY.md +8 -8
  6. data/docs/api-support-matrix.md +9 -8
  7. data/docs/architecture.md +22 -4
  8. data/docs/authentication.md +112 -51
  9. data/docs/commands.md +71 -17
  10. data/docs/configuration-schema-v1.json +53 -7
  11. data/docs/configuration.md +33 -9
  12. data/docs/google-client-compatibility.md +10 -6
  13. data/docs/live-smoke-test.md +159 -0
  14. data/docs/plan-format.md +4 -0
  15. data/docs/plan-schema-v1.json +113 -22
  16. data/docs/rails.md +12 -3
  17. data/docs/reports.md +44 -6
  18. data/docs/safety.md +18 -6
  19. data/docs/troubleshooting.md +42 -13
  20. data/lib/analytics_ops/applier.rb +2 -1
  21. data/lib/analytics_ops/cli/options.rb +157 -0
  22. data/lib/analytics_ops/cli/presenter.rb +221 -0
  23. data/lib/analytics_ops/cli.rb +172 -201
  24. data/lib/analytics_ops/clients/admin.rb +130 -129
  25. data/lib/analytics_ops/clients/admin_normalization.rb +95 -0
  26. data/lib/analytics_ops/clients/data.rb +120 -63
  27. data/lib/analytics_ops/clients/error_translation.rb +45 -0
  28. data/lib/analytics_ops/configuration/document.rb +1 -1
  29. data/lib/analytics_ops/configuration/loader.rb +38 -5
  30. data/lib/analytics_ops/configuration/schema.rb +51 -8
  31. data/lib/analytics_ops/configuration/validator.rb +43 -8
  32. data/lib/analytics_ops/configuration/writer.rb +105 -0
  33. data/lib/analytics_ops/configuration.rb +1 -0
  34. data/lib/analytics_ops/connection.rb +93 -0
  35. data/lib/analytics_ops/desired_state.rb +2 -2
  36. data/lib/analytics_ops/plan.rb +94 -33
  37. data/lib/analytics_ops/planner.rb +10 -2
  38. data/lib/analytics_ops/rails/railtie.rb +13 -18
  39. data/lib/analytics_ops/redaction.rb +14 -9
  40. data/lib/analytics_ops/reports/catalog.rb +22 -5
  41. data/lib/analytics_ops/reports/definition.rb +74 -37
  42. data/lib/analytics_ops/reports/overview.rb +55 -0
  43. data/lib/analytics_ops/reports/overview_result.rb +54 -0
  44. data/lib/analytics_ops/reports/result.rb +38 -9
  45. data/lib/analytics_ops/reports.rb +2 -0
  46. data/lib/analytics_ops/resources.rb +2 -1
  47. data/lib/analytics_ops/service_account.rb +171 -0
  48. data/lib/analytics_ops/setup.rb +171 -0
  49. data/lib/analytics_ops/snapshot.rb +20 -5
  50. data/lib/analytics_ops/version.rb +1 -1
  51. data/lib/analytics_ops/workspace.rb +41 -12
  52. data/lib/analytics_ops.rb +4 -0
  53. data/lib/generators/analytics_ops/install_generator.rb +2 -0
  54. data/lib/generators/analytics_ops/templates/analytics_ops.yml +2 -15
  55. data/sig/analytics_ops.rbs +117 -10
  56. metadata +26 -1
@@ -20,43 +20,100 @@ module AnalyticsOps
20
20
  UNSUPPORTED = 78
21
21
  STALE_PLAN = 79
22
22
  PARTIAL_APPLY = 80
23
+ INTERRUPTED = 130
23
24
 
24
- COMMANDS = %w[doctor discover snapshot audit plan apply verify report realtime schema].freeze
25
+ COMMANDS = %w[
26
+ setup properties doctor discover snapshot audit plan apply verify overview report realtime schema
27
+ ].freeze
25
28
  FORMATS = %w[human json csv].freeze
26
29
  LOG_LEVELS = %w[debug info warn error].freeze
27
- NO_ARGUMENT_COMMANDS = %w[doctor discover snapshot audit plan verify schema].freeze
28
-
29
- def self.start(arguments, out: $stdout, err: $stderr, input: $stdin, workspace_loader: nil)
30
- new(arguments, out:, err:, input:, workspace_loader:).call
31
- end
32
-
33
- def initialize(arguments, out:, err:, input:, workspace_loader:)
30
+ NO_ARGUMENT_COMMANDS = %w[setup properties doctor discover snapshot audit plan verify overview schema].freeze
31
+ CONNECTION_COMMANDS = %w[setup properties discover].freeze
32
+
33
+ def self.start(arguments, out: $stdout, err: $stderr, input: $stdin, workspace_loader: nil,
34
+ connection_loader: nil, service_account_loader: nil, service_account_store: nil)
35
+ new(
36
+ arguments,
37
+ out:,
38
+ err:,
39
+ input:,
40
+ workspace_loader:,
41
+ connection_loader:,
42
+ service_account_loader:,
43
+ service_account_store:
44
+ ).call
45
+ end
46
+
47
+ def initialize(arguments, out:, err:, input:, workspace_loader:, connection_loader:,
48
+ service_account_loader:, service_account_store:)
34
49
  @arguments = arguments.dup
50
+ @json_requested = json_option_present?(@arguments)
35
51
  @out = out
36
52
  @err = err
37
53
  @input = input
38
54
  @workspace_loader = workspace_loader || method(:load_workspace)
39
- @options = default_options
55
+ @connection_loader = connection_loader || method(:load_connection)
56
+ @service_account_loader = service_account_loader || ServiceAccount.method(:load)
57
+ @service_account_store = service_account_store
40
58
  end
41
59
 
42
60
  def call
61
+ with_error_handling { execute_command }
62
+ end
63
+
64
+ private
65
+
66
+ def execute_command
43
67
  command = @arguments.shift
44
- return write(@out, help) if [nil, "help", "--help", "-h"].include?(command)
45
- return write(@out, AnalyticsOps::VERSION) if ["version", "--version", "-v"].include?(command)
68
+ if [nil, "help", "--help", "-h"].include?(command)
69
+ raise OptionParser::InvalidArgument, "help does not accept arguments" if @arguments.any?
70
+
71
+ return write(@out, help)
72
+ end
73
+ if ["version", "--version", "-v"].include?(command)
74
+ raise OptionParser::InvalidArgument, "version does not accept arguments" if @arguments.any?
75
+
76
+ return write(@out, AnalyticsOps::VERSION)
77
+ end
78
+
79
+ options = Options.new(@arguments)
80
+ @options = options.values
81
+ options.parse!(command)
46
82
  return unknown_command(command) unless COMMANDS.include?(command)
47
83
 
48
- parse_options!
49
- validate_command!(command)
84
+ execute(command)
85
+ end
86
+
87
+ def execute(command)
50
88
  return render(Configuration::SCHEMA, status: SUCCESS) if command == "schema"
51
89
 
90
+ if CONNECTION_COMMANDS.include?(command)
91
+ service_account = load_service_account
92
+ connection = @connection_loader.call(
93
+ service_account:,
94
+ transport: @options.fetch(:transport),
95
+ timeout: @options[:timeout],
96
+ logger: operation_logger
97
+ )
98
+ return dispatch_connection(command, connection, service_account)
99
+ end
100
+
101
+ service_account = load_service_account
52
102
  workspace = @workspace_loader.call(
53
103
  config: @options.fetch(:config),
54
104
  profile: @options.fetch(:profile),
105
+ service_account:,
55
106
  transport: @options.fetch(:transport),
56
107
  timeout: @options[:timeout],
57
108
  logger: operation_logger
58
109
  )
59
110
  dispatch(command, workspace)
111
+ end
112
+
113
+ def with_error_handling
114
+ yield
115
+ rescue Interrupt
116
+ error_response(Interrupt.new("Interrupted by user"), INTERRUPTED)
60
117
  rescue OptionParser::ParseError, ConfirmationRequiredError => error
61
118
  error_response(error, USAGE_ERROR)
62
119
  rescue ConfigurationError, InvalidPlanError, ConflictError => error
@@ -79,61 +136,11 @@ module AnalyticsOps
79
136
  error_response(error, REMOTE_ERROR)
80
137
  end
81
138
 
82
- private
83
-
84
- def default_options
85
- {
86
- config: "config/analytics_ops.yml",
87
- profile: "production",
88
- format: "human",
89
- log_level: "warn",
90
- transport: :grpc,
91
- yes: false,
92
- noninteractive: false
93
- }
94
- end
95
-
96
- def parse_options!
97
- parser = OptionParser.new do |value|
98
- value.on("-c", "--config PATH", "Configuration path") { |path| @options[:config] = path }
99
- value.on("-p", "--profile NAME", "Configuration profile") { |name| @options[:profile] = name }
100
- value.on("-f", "--format FORMAT", FORMATS, FORMATS.join(", ")) { |format| @options[:format] = format }
101
- value.on("-o", "--output PATH", "Write a generated plan to PATH") { |path| @options[:output] = path }
102
- value.on("--log-level LEVEL", LOG_LEVELS, LOG_LEVELS.join(", ")) { |level| @options[:log_level] = level }
103
- value.on("--transport TRANSPORT", %w[grpc rest], "grpc or rest") do |transport|
104
- @options[:transport] = transport.to_sym
105
- end
106
- value.on("--timeout SECONDS", Float, "Google API timeout") do |seconds|
107
- raise OptionParser::InvalidArgument, "timeout must be positive" unless seconds.positive?
108
-
109
- @options[:timeout] = seconds
110
- end
111
- value.on("--yes", "Approve every operation in a saved plan") { @options[:yes] = true }
112
- value.on("--non-interactive", "Never prompt") { @options[:noninteractive] = true }
113
- end
114
- parser.parse!(@arguments)
115
- end
116
-
117
- def validate_command!(command)
118
- if NO_ARGUMENT_COMMANDS.include?(command) && @arguments.any?
119
- raise OptionParser::InvalidArgument, "#{command} does not accept positional arguments"
120
- end
121
- raise OptionParser::InvalidArgument, "--output is only valid with plan" if @options[:output] && command != "plan"
122
- if (@options[:yes] || @options[:noninteractive]) && command != "apply"
123
- raise OptionParser::InvalidArgument, "--yes and --non-interactive are only valid with apply"
124
- end
125
- return unless @options.fetch(:format) == "csv" && !%w[report realtime].include?(command)
126
-
127
- raise OptionParser::InvalidArgument, "CSV output is only valid for report results"
128
- end
129
-
130
139
  def dispatch(command, workspace)
131
140
  case command
132
141
  when "doctor"
133
142
  result = workspace.doctor
134
143
  render(result, status: result.success? ? SUCCESS : REMOTE_ERROR)
135
- when "discover"
136
- render(workspace.discover)
137
144
  when "snapshot"
138
145
  render(workspace.snapshot)
139
146
  when "audit"
@@ -146,6 +153,8 @@ module AnalyticsOps
146
153
  when "verify"
147
154
  verification = workspace.verify
148
155
  render(verification, status: verification.converged ? SUCCESS : DRIFT)
156
+ when "overview"
157
+ render(workspace.overview)
149
158
  when "report"
150
159
  render(workspace.report(required_report_name!("report")))
151
160
  when "realtime"
@@ -153,6 +162,42 @@ module AnalyticsOps
153
162
  end
154
163
  end
155
164
 
165
+ def dispatch_connection(command, connection, service_account)
166
+ case command
167
+ when "discover"
168
+ render(connection.discover)
169
+ when "properties"
170
+ render_properties(connection.properties)
171
+ when "setup"
172
+ setup(connection, service_account)
173
+ end
174
+ end
175
+
176
+ def setup(connection, service_account)
177
+ result = Setup.new(
178
+ connection:,
179
+ config: @options.fetch(:config),
180
+ profile: @options.fetch(:profile),
181
+ property_id: @options[:property],
182
+ noninteractive: @options[:noninteractive],
183
+ input: @input,
184
+ out: @out
185
+ ).call
186
+ service_account_store.write(service_account.path)
187
+
188
+ if human?
189
+ action = result.created? ? "Created" : "Using"
190
+ @out.puts "#{action} #{Redaction.message(result.config_path)}"
191
+ @out.puts "Connected #{Redaction.message(result.profile)} to " \
192
+ "#{Redaction.message(result.property.display_name)} " \
193
+ "(property #{Redaction.message(result.property.id)})."
194
+ @out.puts "Next: analytics-ops overview"
195
+ SUCCESS
196
+ else
197
+ render(result)
198
+ end
199
+ end
200
+
156
201
  def render_plan(plan)
157
202
  plan.write(@options.fetch(:output)) if @options[:output]
158
203
  render(plan)
@@ -195,154 +240,51 @@ module AnalyticsOps
195
240
  true
196
241
  end
197
242
 
198
- def load_workspace(config:, profile:, transport:, timeout:, logger:)
199
- Workspace.load(config, profile:, transport:, timeout:, logger:)
243
+ def load_workspace(config:, profile:, service_account:, transport:, timeout:, logger:)
244
+ Workspace.load(config, profile:, service_account:, transport:, timeout:, logger:)
200
245
  end
201
246
 
202
- def operation_logger
203
- @operation_logger ||= Logger.new(@err).tap do |logger|
204
- logger.level = Logger.const_get(@options.fetch(:log_level).upcase)
205
- logger.formatter = ->(_severity, _time, _program, message) { "#{message}\n" }
206
- end
247
+ def load_connection(service_account:, transport:, timeout:, logger:)
248
+ Connection.new(service_account:, transport:, timeout:, logger:)
207
249
  end
208
250
 
209
- def render(value, status: SUCCESS)
210
- case @options.fetch(:format)
211
- when "json"
212
- @out.write(json(value))
213
- when "csv"
214
- @out.write(csv(value))
215
- else
216
- @out.puts(human(value))
217
- end
218
- status
219
- end
220
-
221
- def json(value)
222
- payload = serializable(value)
223
- "#{JSON.pretty_generate(Canonical.normalize(payload))}\n"
224
- end
225
-
226
- def csv(value)
227
- unless value.is_a?(Reports::Result)
228
- raise OptionParser::InvalidArgument, "CSV output is only valid for report results"
229
- end
230
-
231
- lines = [value.headers]
232
- value.rows.each { |row| lines << value.headers.map { |header| csv_cell(row.fetch(header)) } }
233
- "#{lines.map { |line| line.map { |cell| csv_field(cell) }.join(",") }.join("\n")}\n"
234
- end
235
-
236
- def csv_cell(value)
237
- string = value.to_s
238
- string.match?(/\A[=+\-@]/) ? "'#{string}" : string
251
+ def load_service_account
252
+ @service_account_loader.call(
253
+ path: @options[:service_account],
254
+ store: service_account_store
255
+ )
239
256
  end
240
257
 
241
- def csv_field(value)
242
- string = value.to_s
243
- return string unless string.match?(/[",\r\n]/)
244
-
245
- "\"#{string.gsub("\"", "\"\"")}\""
258
+ def service_account_store
259
+ @service_account_store ||= ServiceAccount::Store.new
246
260
  end
247
261
 
248
- def serializable(value)
249
- case value
250
- when Array
251
- value.map { |item| serializable(item) }
252
- when Hash
253
- value.to_h { |key, item| [key.to_s, serializable(item)] }
254
- else
255
- value.respond_to?(:to_h) ? serializable(value.to_h) : value
262
+ def operation_logger
263
+ @operation_logger ||= Logger.new(@err).tap do |logger|
264
+ logger.level = Logger.const_get(@options.fetch(:log_level).upcase)
265
+ logger.formatter = ->(_severity, _time, _program, message) { "#{message}\n" }
256
266
  end
257
267
  end
258
268
 
259
- def human(value)
260
- case value
261
- when Plan
262
- human_plan(value)
263
- when Snapshot
264
- "Snapshot #{value.fingerprint}\n#{JSON.pretty_generate(Canonical.normalize(value.to_h))}"
265
- when Workspace::DoctorResult
266
- human_doctor(value)
267
- when Workspace::Verification
268
- "#{value.converged ? "Converged" : "Drift found"}: #{value.plan.changes.length} planned changes"
269
- when Applier::Result
270
- "Apply #{value.status}: #{value.applied.length} changes completed"
271
- when Reports::Result
272
- human_report(value)
273
- when Array
274
- human_discovery(value)
275
- else
276
- JSON.pretty_generate(Canonical.normalize(serializable(value)))
277
- end
269
+ def render(value, status: SUCCESS)
270
+ presenter.render(value, status:)
278
271
  end
279
272
 
280
- def human_doctor(value)
281
- value.checks.map do |check|
282
- "#{check.fetch("status").upcase.ljust(11)} #{check.fetch("name")}: #{check.fetch("detail")}"
283
- end.join("\n")
273
+ def json(value)
274
+ presenter.json(value)
284
275
  end
285
276
 
286
277
  def human_plan(plan, detailed: false)
287
- lines = [
288
- "Plan for profile #{plan.profile} / property #{plan.property_id}",
289
- "Snapshot: #{plan.snapshot_fingerprint}",
290
- "Changes: #{plan.changes.length}; findings: #{plan.findings.length}"
291
- ]
292
- plan.changes.each do |change|
293
- lines << " #{change.operation.upcase.ljust(6)} #{change.resource_type} #{change.resource_identity}"
294
- next unless detailed
295
-
296
- lines << " before: #{JSON.generate(Canonical.normalize(change.before))}"
297
- lines << " after: #{JSON.generate(Canonical.normalize(change.after))}"
298
- lines << " rollback: #{change.rollback}"
299
- end
300
- plan.findings.each do |finding|
301
- lines << " #{finding.severity.upcase.ljust(12)} #{finding.resource_identity}: #{finding.message}"
302
- end
303
- lines.join("\n")
304
- end
305
-
306
- def human_report(result)
307
- lines = ["Report #{result.name} (#{result.kind}) — #{result.row_count} rows"]
308
- return (lines << "No rows returned").join("\n") if result.rows.empty?
309
-
310
- widths = result.headers.to_h do |header|
311
- values = result.rows.map { |row| row.fetch(header).to_s }
312
- [header, ([header.length] + values.map(&:length)).max.clamp(1, 60)]
313
- end
314
- lines << table_row(result.headers, widths)
315
- lines << result.headers.map { |header| "-" * widths.fetch(header) }.join("-+-")
316
- result.rows.each do |row|
317
- lines << table_row(result.headers.map { |header| row.fetch(header) }, widths, headers: result.headers)
318
- end
319
- lines.join("\n")
320
- end
321
-
322
- def table_row(values, widths, headers: values)
323
- values.zip(headers).map do |value, header|
324
- value.to_s.slice(0, widths.fetch(header)).ljust(widths.fetch(header))
325
- end.join(" | ")
278
+ presenter.human_plan(plan, detailed:)
326
279
  end
327
280
 
328
- def human_discovery(accounts)
329
- return "No accessible Google Analytics accounts found" if accounts.empty?
330
-
331
- accounts.flat_map do |account|
332
- lines = ["Account #{account.id}: #{account.display_name}"]
333
- account.properties.each do |property|
334
- lines << " Property #{property.fetch("id")}: #{property.fetch("display_name")}"
335
- property.fetch("streams").each do |stream|
336
- lines << " Stream #{stream.fetch("id")}: #{stream.fetch("display_name")} (#{stream.fetch("type")})"
337
- end
338
- end
339
- lines
340
- end.join("\n")
281
+ def render_properties(accounts)
282
+ presenter.render_properties(accounts)
341
283
  end
342
284
 
343
285
  def render_error_result(error, status)
344
286
  if json?
345
- @err.write(json("error" => error_payload(error), "result" => serializable(error.result)))
287
+ @err.write(json("error" => error_payload(error), "result" => error.result))
346
288
  else
347
289
  @err.puts "#{error_name(error)}: #{Redaction.message(error.message)}"
348
290
  @err.write(json(error.result))
@@ -368,11 +310,16 @@ module AnalyticsOps
368
310
  end
369
311
 
370
312
  def human?
371
- @options.fetch(:format) == "human"
313
+ @options&.fetch(:format, "human") == "human"
372
314
  end
373
315
 
374
316
  def json?
375
- @options.fetch(:format) == "json"
317
+ @json_requested || @options&.fetch(:format, "human") == "json"
318
+ end
319
+
320
+ def presenter
321
+ format = json? ? "json" : @options&.fetch(:format, "human") || "human"
322
+ @presenter ||= Presenter.new(out: @out, format:)
376
323
  end
377
324
 
378
325
  def write(stream, message, status = SUCCESS)
@@ -381,10 +328,22 @@ module AnalyticsOps
381
328
  end
382
329
 
383
330
  def unknown_command(command)
331
+ if json?
332
+ error = OptionParser::InvalidArgument.new("Unknown command: #{Redaction.message(command)}")
333
+ return error_response(error, USAGE_ERROR)
334
+ end
335
+
384
336
  @err.puts "Unknown command: #{Redaction.message(command)}"
385
337
  write(@err, "Run `analytics-ops help` for available commands.", USAGE_ERROR)
386
338
  end
387
339
 
340
+ def json_option_present?(arguments)
341
+ arguments.each_with_index.any? do |argument, index|
342
+ argument == "--json" || argument == "--format=json" || argument == "-fjson" ||
343
+ (%w[--format -f].include?(argument) && arguments[index + 1] == "json")
344
+ end
345
+ end
346
+
388
347
  def help
389
348
  <<~HELP
390
349
  Analytics Ops #{AnalyticsOps::VERSION}
@@ -393,9 +352,14 @@ module AnalyticsOps
393
352
  Usage:
394
353
  analytics-ops COMMAND [options]
395
354
 
355
+ Start here:
356
+ setup Connect Google, choose a property, and create configuration
357
+ overview Show a useful five-section summary for the selected property
358
+ properties List accessible accounts and properties without configuration
359
+
396
360
  Read-only commands:
397
361
  doctor Validate local setup and Google API/property access
398
- discover List accessible accounts, properties, and data streams
362
+ discover List accessible accounts, properties, and streams without configuration
399
363
  snapshot Print normalized remote configuration
400
364
  audit Compare desired and remote state (exit 2 for drift)
401
365
  plan Generate a deterministic plan; use --output to save it
@@ -412,16 +376,23 @@ module AnalyticsOps
412
376
  version Print the installed version
413
377
 
414
378
  Common options:
415
- -c, --config PATH Default: config/analytics_ops.yml
416
- -p, --profile NAME Default: production
417
- -f, --format FORMAT human, json, or report-only csv
418
- -o, --output PATH Save a generated plan (plan only)
419
- --transport NAME grpc or rest
420
- --timeout SECONDS
421
- --log-level LEVEL debug, info, warn, or error
422
- --yes Required for non-interactive apply
423
- --non-interactive Never prompt
379
+ -c, --config PATH Default: config/analytics_ops.yml
380
+ -p, --profile NAME Default: production
381
+ -f, --format FORMAT human, json, or report-only csv
382
+ --json Shortcut for --format json
383
+ --csv Shortcut for --format csv
384
+ -o, --output PATH Save a generated plan (plan only)
385
+ --property ID Select a property without prompting (setup only)
386
+ --service-account PATH Google service-account JSON key (setup only)
387
+ --transport NAME grpc or rest
388
+ --timeout SECONDS Positive Google API timeout
389
+ --log-level LEVEL debug, info, warn, or error
390
+ --yes Required for non-interactive apply
391
+ --non-interactive Never prompt
424
392
  HELP
425
393
  end
426
394
  end
427
395
  end
396
+
397
+ require_relative "cli/presenter"
398
+ require_relative "cli/options"