analytics_ops 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +47 -0
  3. data/CODE_OF_CONDUCT.md +10 -0
  4. data/CONTRIBUTING.md +67 -0
  5. data/LICENSE.txt +21 -0
  6. data/README.md +188 -0
  7. data/SECURITY.md +56 -0
  8. data/docs/api-support-matrix.md +50 -0
  9. data/docs/architecture.md +89 -0
  10. data/docs/authentication.md +86 -0
  11. data/docs/commands.md +112 -0
  12. data/docs/configuration-schema-v1.json +129 -0
  13. data/docs/configuration.md +167 -0
  14. data/docs/google-client-compatibility.md +59 -0
  15. data/docs/plan-format.md +89 -0
  16. data/docs/plan-schema-v1.json +224 -0
  17. data/docs/rails.md +73 -0
  18. data/docs/reports.md +124 -0
  19. data/docs/safety.md +93 -0
  20. data/docs/troubleshooting.md +103 -0
  21. data/exe/analytics-ops +6 -0
  22. data/lib/analytics_ops/applier.rb +52 -0
  23. data/lib/analytics_ops/canonical.rb +65 -0
  24. data/lib/analytics_ops/cli.rb +427 -0
  25. data/lib/analytics_ops/clients/admin.rb +446 -0
  26. data/lib/analytics_ops/clients/data.rb +314 -0
  27. data/lib/analytics_ops/configuration/document.rb +25 -0
  28. data/lib/analytics_ops/configuration/loader.rb +76 -0
  29. data/lib/analytics_ops/configuration/schema.rb +107 -0
  30. data/lib/analytics_ops/configuration/validator.rb +344 -0
  31. data/lib/analytics_ops/configuration.rb +19 -0
  32. data/lib/analytics_ops/desired_state.rb +40 -0
  33. data/lib/analytics_ops/errors.rb +31 -0
  34. data/lib/analytics_ops/plan.rb +551 -0
  35. data/lib/analytics_ops/planner.rb +233 -0
  36. data/lib/analytics_ops/rails/railtie.rb +65 -0
  37. data/lib/analytics_ops/rails.rb +5 -0
  38. data/lib/analytics_ops/redaction.rb +46 -0
  39. data/lib/analytics_ops/reports/catalog.rb +100 -0
  40. data/lib/analytics_ops/reports/definition.rb +226 -0
  41. data/lib/analytics_ops/reports/result.rb +90 -0
  42. data/lib/analytics_ops/reports.rb +13 -0
  43. data/lib/analytics_ops/resources.rb +79 -0
  44. data/lib/analytics_ops/snapshot.rb +45 -0
  45. data/lib/analytics_ops/version.rb +5 -0
  46. data/lib/analytics_ops/workspace.rb +212 -0
  47. data/lib/analytics_ops.rb +21 -0
  48. data/lib/generators/analytics_ops/install_generator.rb +23 -0
  49. data/lib/generators/analytics_ops/templates/analytics-ops +9 -0
  50. data/lib/generators/analytics_ops/templates/analytics_ops.yml +23 -0
  51. data/sig/analytics_ops.rbs +395 -0
  52. metadata +131 -0
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Shared deterministic value helpers.
4
+
5
+ require "digest"
6
+ require "json"
7
+
8
+ module AnalyticsOps
9
+ # Deterministic serialization shared by snapshots and saved plans.
10
+ module Canonical
11
+ module_function
12
+
13
+ def normalize(value)
14
+ case value
15
+ when Hash
16
+ value.each_with_object({}) do |(key, child), result|
17
+ result[key.to_s] = normalize(child)
18
+ end.sort.to_h
19
+ when Array
20
+ value.map { |child| normalize(child) }
21
+ else
22
+ value
23
+ end
24
+ end
25
+
26
+ def json(value)
27
+ JSON.generate(normalize(value))
28
+ end
29
+
30
+ def fingerprint(value)
31
+ "sha256:#{Digest::SHA256.hexdigest(json(value))}"
32
+ end
33
+
34
+ def deep_freeze(value)
35
+ case value
36
+ when Hash
37
+ value.each do |key, child|
38
+ deep_freeze(key)
39
+ deep_freeze(child)
40
+ end
41
+ when Array
42
+ value.each { |child| deep_freeze(child) }
43
+ end
44
+
45
+ value.freeze
46
+ end
47
+
48
+ def immutable(value)
49
+ deep_freeze(copy(value))
50
+ end
51
+
52
+ def copy(value)
53
+ case value
54
+ when Hash
55
+ value.to_h { |key, child| [copy(key), copy(child)] }
56
+ when Array
57
+ value.map { |child| copy(child) }
58
+ when String
59
+ value.dup
60
+ else
61
+ value
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,427 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "logger"
5
+ require "optparse"
6
+ require_relative "../analytics_ops"
7
+
8
+ module AnalyticsOps
9
+ # Command-line consumer of the same public Workspace API available to Ruby callers.
10
+ class CLI
11
+ SUCCESS = 0
12
+ DRIFT = 2
13
+ USAGE_ERROR = 64
14
+ CONFIGURATION_ERROR = 65
15
+ AUTHENTICATION_ERROR = 66
16
+ REMOTE_ERROR = 69
17
+ TIMEOUT_ERROR = 74
18
+ QUOTA_ERROR = 75
19
+ AUTHORIZATION_ERROR = 77
20
+ UNSUPPORTED = 78
21
+ STALE_PLAN = 79
22
+ PARTIAL_APPLY = 80
23
+
24
+ COMMANDS = %w[doctor discover snapshot audit plan apply verify report realtime schema].freeze
25
+ FORMATS = %w[human json csv].freeze
26
+ 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:)
34
+ @arguments = arguments.dup
35
+ @out = out
36
+ @err = err
37
+ @input = input
38
+ @workspace_loader = workspace_loader || method(:load_workspace)
39
+ @options = default_options
40
+ end
41
+
42
+ def call
43
+ 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)
46
+ return unknown_command(command) unless COMMANDS.include?(command)
47
+
48
+ parse_options!
49
+ validate_command!(command)
50
+ return render(Configuration::SCHEMA, status: SUCCESS) if command == "schema"
51
+
52
+ workspace = @workspace_loader.call(
53
+ config: @options.fetch(:config),
54
+ profile: @options.fetch(:profile),
55
+ transport: @options.fetch(:transport),
56
+ timeout: @options[:timeout],
57
+ logger: operation_logger
58
+ )
59
+ dispatch(command, workspace)
60
+ rescue OptionParser::ParseError, ConfirmationRequiredError => error
61
+ error_response(error, USAGE_ERROR)
62
+ rescue ConfigurationError, InvalidPlanError, ConflictError => error
63
+ error_response(error, CONFIGURATION_ERROR)
64
+ rescue AuthenticationError => error
65
+ error_response(error, AUTHENTICATION_ERROR)
66
+ rescue AuthorizationError => error
67
+ error_response(error, AUTHORIZATION_ERROR)
68
+ rescue UnsupportedCapabilityError => error
69
+ error_response(error, UNSUPPORTED)
70
+ rescue StalePlanError => error
71
+ error_response(error, STALE_PLAN)
72
+ rescue PartialApplyError => error
73
+ render_error_result(error, PARTIAL_APPLY)
74
+ rescue QuotaError => error
75
+ error_response(error, QUOTA_ERROR)
76
+ rescue TimeoutError => error
77
+ error_response(error, TIMEOUT_ERROR)
78
+ rescue InvalidRequestError, RemoteError => error
79
+ error_response(error, REMOTE_ERROR)
80
+ end
81
+
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
+ def dispatch(command, workspace)
131
+ case command
132
+ when "doctor"
133
+ result = workspace.doctor
134
+ render(result, status: result.success? ? SUCCESS : REMOTE_ERROR)
135
+ when "discover"
136
+ render(workspace.discover)
137
+ when "snapshot"
138
+ render(workspace.snapshot)
139
+ when "audit"
140
+ plan = workspace.audit
141
+ render(plan, status: plan.drift? ? DRIFT : SUCCESS)
142
+ when "plan"
143
+ render_plan(workspace.plan)
144
+ when "apply"
145
+ apply(workspace)
146
+ when "verify"
147
+ verification = workspace.verify
148
+ render(verification, status: verification.converged ? SUCCESS : DRIFT)
149
+ when "report"
150
+ render(workspace.report(required_report_name!("report")))
151
+ when "realtime"
152
+ render(workspace.realtime(optional_realtime_name!))
153
+ end
154
+ end
155
+
156
+ def render_plan(plan)
157
+ plan.write(@options.fetch(:output)) if @options[:output]
158
+ render(plan)
159
+ end
160
+
161
+ def apply(workspace)
162
+ path = required_plan_path!
163
+ saved_plan = Plan.load(path)
164
+ @out.puts(human_plan(saved_plan, detailed: true)) if human? && !@options[:yes]
165
+ confirmed = @options[:yes] || interactive_confirmation?
166
+ render(workspace.apply(saved_plan, confirm: confirmed))
167
+ end
168
+
169
+ def required_plan_path!
170
+ raise OptionParser::MissingArgument, "apply requires exactly one PLAN_FILE" unless @arguments.length == 1
171
+
172
+ @arguments.first
173
+ end
174
+
175
+ def required_report_name!(command)
176
+ raise OptionParser::MissingArgument, "#{command} requires exactly one REPORT_NAME" unless @arguments.length == 1
177
+
178
+ @arguments.first
179
+ end
180
+
181
+ def optional_realtime_name!
182
+ raise OptionParser::InvalidArgument, "realtime accepts at most one REPORT_NAME" if @arguments.length > 1
183
+
184
+ @arguments.first || "realtime_events"
185
+ end
186
+
187
+ def interactive_confirmation?
188
+ raise ConfirmationRequiredError, "Non-interactive apply requires --yes" if @options[:noninteractive]
189
+
190
+ @out.print "Apply every change in this saved plan? Type `yes` to continue: "
191
+ @out.flush
192
+ answer = @input.gets
193
+ raise ConfirmationRequiredError, "Apply was not confirmed" unless answer&.strip == "yes"
194
+
195
+ true
196
+ end
197
+
198
+ def load_workspace(config:, profile:, transport:, timeout:, logger:)
199
+ Workspace.load(config, profile:, transport:, timeout:, logger:)
200
+ end
201
+
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
207
+ end
208
+
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
239
+ end
240
+
241
+ def csv_field(value)
242
+ string = value.to_s
243
+ return string unless string.match?(/[",\r\n]/)
244
+
245
+ "\"#{string.gsub("\"", "\"\"")}\""
246
+ end
247
+
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
256
+ end
257
+ end
258
+
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
278
+ end
279
+
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")
284
+ end
285
+
286
+ 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(" | ")
326
+ end
327
+
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")
341
+ end
342
+
343
+ def render_error_result(error, status)
344
+ if json?
345
+ @err.write(json("error" => error_payload(error), "result" => serializable(error.result)))
346
+ else
347
+ @err.puts "#{error_name(error)}: #{Redaction.message(error.message)}"
348
+ @err.write(json(error.result))
349
+ end
350
+ status
351
+ end
352
+
353
+ def error_response(error, status)
354
+ if json?
355
+ @err.write(json("error" => error_payload(error)))
356
+ else
357
+ @err.puts "#{error_name(error)}: #{Redaction.message(error.message)}"
358
+ end
359
+ status
360
+ end
361
+
362
+ def error_payload(error)
363
+ { "type" => error_name(error), "message" => Redaction.message(error.message) }
364
+ end
365
+
366
+ def error_name(error)
367
+ error.class.name.split("::").last
368
+ end
369
+
370
+ def human?
371
+ @options.fetch(:format) == "human"
372
+ end
373
+
374
+ def json?
375
+ @options.fetch(:format) == "json"
376
+ end
377
+
378
+ def write(stream, message, status = SUCCESS)
379
+ stream.puts message
380
+ status
381
+ end
382
+
383
+ def unknown_command(command)
384
+ @err.puts "Unknown command: #{Redaction.message(command)}"
385
+ write(@err, "Run `analytics-ops help` for available commands.", USAGE_ERROR)
386
+ end
387
+
388
+ def help
389
+ <<~HELP
390
+ Analytics Ops #{AnalyticsOps::VERSION}
391
+ Google Analytics 4 configuration as code and reporting for Ruby and Rails.
392
+
393
+ Usage:
394
+ analytics-ops COMMAND [options]
395
+
396
+ Read-only commands:
397
+ doctor Validate local setup and Google API/property access
398
+ discover List accessible accounts, properties, and data streams
399
+ snapshot Print normalized remote configuration
400
+ audit Compare desired and remote state (exit 2 for drift)
401
+ plan Generate a deterministic plan; use --output to save it
402
+ verify Prove whether managed state converges
403
+ report NAME Run a built-in standard report
404
+ realtime Run realtime_events (or pass another realtime recipe)
405
+ schema Print the version-1 configuration schema
406
+
407
+ Mutation command:
408
+ apply FILE Apply only a saved, non-stale plan; prompts unless --yes
409
+
410
+ Other commands:
411
+ help Show this help
412
+ version Print the installed version
413
+
414
+ 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
424
+ HELP
425
+ end
426
+ end
427
+ end