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
@@ -0,0 +1,157 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AnalyticsOps
4
+ class CLI
5
+ # Parses and validates command-line options without loading a workspace.
6
+ class Options
7
+ DEFAULTS = {
8
+ config: "config/analytics_ops.yml",
9
+ profile: "production",
10
+ format: "human",
11
+ log_level: "warn",
12
+ transport: :grpc,
13
+ yes: false,
14
+ noninteractive: false
15
+ }.freeze
16
+ SETUP_OPTIONS = %i[property service_account].freeze
17
+ PROPERTY_ID = /\A\d{1,50}\z/
18
+ PROFILE = /\A[A-Za-z][A-Za-z0-9_]{0,63}\z/
19
+
20
+ attr_reader :values
21
+
22
+ def initialize(arguments)
23
+ @arguments = arguments
24
+ @values = DEFAULTS.dup
25
+ @explicit_format = nil
26
+ end
27
+
28
+ def parse!(command)
29
+ parser.parse!(@arguments)
30
+ validate!(command)
31
+ @values.freeze
32
+ end
33
+
34
+ private
35
+
36
+ def parser
37
+ OptionParser.new do |options|
38
+ add_file_options(options)
39
+ add_format_options(options)
40
+ add_setup_options(options)
41
+ add_client_options(options)
42
+ add_execution_options(options)
43
+ end
44
+ end
45
+
46
+ 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 }
50
+ end
51
+
52
+ def add_format_options(options)
53
+ options.on("-f", "--format FORMAT", CLI::FORMATS, CLI::FORMATS.join(", ")) do |format|
54
+ select_format(format)
55
+ end
56
+ options.on("--json", "Use JSON output") { select_format("json") }
57
+ options.on("--csv", "Use CSV report output") { select_format("csv") }
58
+ end
59
+
60
+ def select_format(format)
61
+ if @explicit_format && @explicit_format != format
62
+ raise OptionParser::InvalidArgument, "choose exactly one output format"
63
+ end
64
+
65
+ @explicit_format = format
66
+ @values[:format] = format
67
+ end
68
+
69
+ def add_setup_options(options)
70
+ options.on("--property ID", "Property to select during setup") { |id| @values[:property] = id }
71
+ options.on("--service-account PATH", "Google service-account JSON key for setup") do |path|
72
+ @values[:service_account] = path
73
+ end
74
+ end
75
+
76
+ def add_client_options(options)
77
+ options.on("--log-level LEVEL", CLI::LOG_LEVELS, CLI::LOG_LEVELS.join(", ")) do |level|
78
+ @values[:log_level] = level
79
+ end
80
+ options.on("--transport TRANSPORT", %w[grpc rest], "grpc or rest") do |transport|
81
+ @values[:transport] = transport.to_sym
82
+ end
83
+ options.on("--timeout SECONDS", Float, "Google API timeout") do |seconds|
84
+ unless seconds.finite? && seconds.positive?
85
+ raise OptionParser::InvalidArgument, "timeout must be finite and positive"
86
+ end
87
+
88
+ @values[:timeout] = seconds
89
+ end
90
+ end
91
+
92
+ def add_execution_options(options)
93
+ options.on("--yes", "Approve every operation in a saved plan") { @values[:yes] = true }
94
+ options.on("--non-interactive", "Never prompt") { @values[:noninteractive] = true }
95
+ end
96
+
97
+ def validate!(command)
98
+ validate_arguments!(command)
99
+ validate_scoped_options!(command)
100
+ validate_setup! if command == "setup"
101
+ validate_format!(command)
102
+ end
103
+
104
+ def validate_arguments!(command)
105
+ return unless CLI::NO_ARGUMENT_COMMANDS.include?(command) && @arguments.any?
106
+
107
+ raise OptionParser::InvalidArgument, "#{command} does not accept positional arguments"
108
+ end
109
+
110
+ def validate_scoped_options!(command)
111
+ raise OptionParser::InvalidArgument, "--output is only valid with plan" if @values[:output] && command != "plan"
112
+ 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"
115
+ end
116
+ return if SETUP_OPTIONS.none? { |name| @values[name] } || command == "setup"
117
+
118
+ raise OptionParser::InvalidArgument, "--property and --service-account are setup-only"
119
+ end
120
+
121
+ def validate_setup!
122
+ validate_setup_property!
123
+ raise OptionParser::InvalidArgument, "--profile is invalid" unless PROFILE.match?(@values.fetch(:profile))
124
+
125
+ validate_service_account!
126
+ validate_setup_json!
127
+ end
128
+
129
+ def validate_setup_property!
130
+ if @values[:noninteractive] && !@values[:property]
131
+ raise OptionParser::MissingArgument, "Non-interactive setup requires --property ID"
132
+ end
133
+ return unless @values[:property] && !PROPERTY_ID.match?(@values.fetch(:property))
134
+
135
+ raise OptionParser::InvalidArgument, "--property must be a numeric GA4 property ID"
136
+ end
137
+
138
+ def validate_service_account!
139
+ return unless @values[:service_account] && !File.file?(@values.fetch(:service_account))
140
+
141
+ raise OptionParser::InvalidArgument, "--service-account must identify an existing file"
142
+ end
143
+
144
+ def validate_setup_json!
145
+ return unless @values.fetch(:format) == "json" && (!@values[:noninteractive] || !@values[:property])
146
+
147
+ raise OptionParser::MissingArgument, "JSON setup requires --non-interactive --property ID"
148
+ end
149
+
150
+ def validate_format!(command)
151
+ return unless @values.fetch(:format) == "csv" && !%w[report realtime].include?(command)
152
+
153
+ raise OptionParser::InvalidArgument, "CSV output is only valid for report results"
154
+ end
155
+ end
156
+ end
157
+ end
@@ -0,0 +1,221 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AnalyticsOps
4
+ class CLI
5
+ # Formats gem-owned values for terminal and automation consumers.
6
+ class Presenter
7
+ OVERVIEW_LABELS = {
8
+ "overview_totals" => "Totals",
9
+ "overview_trend" => "Daily trend",
10
+ "overview_acquisition" => "Traffic acquisition",
11
+ "overview_landing_pages" => "Landing pages",
12
+ "overview_devices" => "Devices"
13
+ }.freeze
14
+
15
+ def initialize(out:, format:)
16
+ @out = out
17
+ @format = format
18
+ end
19
+
20
+ def render(value, status: CLI::SUCCESS)
21
+ case @format
22
+ when "json"
23
+ @out.write(json(value))
24
+ when "csv"
25
+ @out.write(csv(value))
26
+ else
27
+ @out.puts(human(value))
28
+ end
29
+ status
30
+ end
31
+
32
+ def render_properties(accounts)
33
+ return render(accounts) unless @format == "human"
34
+ return write("No accessible Google Analytics properties found") if accounts.empty?
35
+
36
+ lines = accounts.flat_map do |account|
37
+ ["Account #{display(account.id)}: #{display(account.display_name)}"] + account.properties.map do |property|
38
+ " Property #{display(property.fetch("id"))}: #{display(property.fetch("display_name"))}"
39
+ end
40
+ end
41
+ write(lines.join("\n"))
42
+ end
43
+
44
+ def json(value)
45
+ payload = serializable(value)
46
+ "#{JSON.pretty_generate(Canonical.normalize(payload))}\n"
47
+ end
48
+
49
+ def human_plan(plan, detailed: false)
50
+ lines = [
51
+ "Plan for profile #{display(plan.profile)} / property #{display(plan.property_id)}",
52
+ "Snapshot: #{display(plan.snapshot_fingerprint)}",
53
+ "Changes: #{plan.changes.length}; findings: #{plan.findings.length}"
54
+ ]
55
+ plan.changes.each do |change|
56
+ lines.concat(human_plan_change(change, detailed:))
57
+ end
58
+ plan.findings.each do |finding|
59
+ lines << " #{display(finding.severity).upcase.ljust(12)} #{display(finding.resource_identity)}: " \
60
+ "#{display(finding.message)}"
61
+ end
62
+ lines.join("\n")
63
+ end
64
+
65
+ private
66
+
67
+ def csv(value)
68
+ unless value.is_a?(Reports::Result)
69
+ raise OptionParser::InvalidArgument, "CSV output is only valid for report results"
70
+ end
71
+
72
+ lines = [value.headers.map { |header| csv_cell(header) }]
73
+ value.rows.each { |row| lines << value.headers.map { |header| csv_cell(row.fetch(header)) } }
74
+ "#{lines.map { |line| line.map { |cell| csv_field(cell) }.join(",") }.join("\n")}\n"
75
+ end
76
+
77
+ def human_plan_change(change, detailed:)
78
+ lines = [
79
+ " #{display(change.operation).upcase.ljust(6)} #{display(change.resource_type)} " \
80
+ "#{display(change.resource_identity)}"
81
+ ]
82
+ return lines unless detailed
83
+
84
+ lines << " before: #{display(JSON.generate(Canonical.normalize(change.before)))}"
85
+ lines << " after: #{display(JSON.generate(Canonical.normalize(change.after)))}"
86
+ lines << " rollback: #{display(change.rollback)}"
87
+ end
88
+
89
+ def csv_cell(value)
90
+ string = value.to_s.gsub(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/, "?")
91
+ dangerous = string.match?(/\A(?:[\p{Space}\uFEFF]*[=+\-@]|[\t\r\n])/)
92
+ dangerous ? "'#{string}" : string
93
+ end
94
+
95
+ def csv_field(value)
96
+ string = value.to_s
97
+ return string unless string.match?(/[",\r\n]/)
98
+
99
+ "\"#{string.gsub("\"", "\"\"")}\""
100
+ end
101
+
102
+ def serializable(value)
103
+ case value
104
+ when Array
105
+ value.map { |item| serializable(item) }
106
+ when Hash
107
+ value.to_h { |key, item| [key.to_s, serializable(item)] }
108
+ else
109
+ value.respond_to?(:to_h) ? serializable(value.to_h) : value
110
+ end
111
+ end
112
+
113
+ def human(value)
114
+ case value
115
+ when Plan
116
+ human_plan(value)
117
+ when Snapshot
118
+ "Snapshot #{display(value.fingerprint)}\n" \
119
+ "#{JSON.pretty_generate(Canonical.normalize(safe_human_value(value.to_h)))}"
120
+ when Workspace::DoctorResult
121
+ human_doctor(value)
122
+ when Workspace::Verification
123
+ "#{value.converged ? "Converged" : "Drift found"}: #{value.plan.changes.length} planned changes"
124
+ when Applier::Result
125
+ "Apply #{value.status}: #{value.applied.length} changes completed"
126
+ when Reports::Result
127
+ human_report(value)
128
+ when Reports::OverviewResult
129
+ human_overview(value)
130
+ when Array
131
+ human_discovery(value)
132
+ else
133
+ JSON.pretty_generate(Canonical.normalize(serializable(value)))
134
+ end
135
+ end
136
+
137
+ def human_doctor(value)
138
+ value.checks.map do |check|
139
+ status = display(check.fetch("status")).upcase.ljust(11)
140
+ "#{status} #{display(check.fetch("name"))}: #{display(check.fetch("detail"))}"
141
+ end.join("\n")
142
+ end
143
+
144
+ def human_report(result)
145
+ [
146
+ "Report #{display(result.name)} (#{display(result.kind)}) — #{result.row_count} rows",
147
+ *human_report_table(result)
148
+ ].join("\n")
149
+ end
150
+
151
+ def human_overview(overview)
152
+ lines = ["Overview for property #{display(overview.property_id)} — previous 28 complete days"]
153
+ overview.reports.each do |report|
154
+ lines << ""
155
+ lines << OVERVIEW_LABELS.fetch(report.name, report.name)
156
+ lines.concat(human_report_table(report))
157
+ end
158
+ lines.join("\n")
159
+ end
160
+
161
+ def human_report_table(result)
162
+ return ["No rows returned"] if result.rows.empty?
163
+
164
+ widths = result.headers.to_h do |header|
165
+ values = result.rows.map { |row| display(row.fetch(header)) }
166
+ [header, ([display(header).length] + values.map(&:length)).max.clamp(1, 60)]
167
+ end
168
+ lines = [table_row(result.headers, widths)]
169
+ lines << result.headers.map { |header| "-" * widths.fetch(header) }.join("-+-")
170
+ result.rows.each do |row|
171
+ lines << table_row(result.headers.map { |header| row.fetch(header) }, widths, headers: result.headers)
172
+ end
173
+ lines
174
+ end
175
+
176
+ def table_row(values, widths, headers: values)
177
+ values.zip(headers).map do |value, header|
178
+ display(value).slice(0, widths.fetch(header)).ljust(widths.fetch(header))
179
+ end.join(" | ")
180
+ end
181
+
182
+ def human_discovery(accounts)
183
+ return "No accessible Google Analytics accounts found" if accounts.empty?
184
+
185
+ accounts.flat_map do |account|
186
+ lines = ["Account #{display(account.id)}: #{display(account.display_name)}"]
187
+ account.properties.each do |property|
188
+ lines << " Property #{display(property.fetch("id"))}: #{display(property.fetch("display_name"))}"
189
+ property.fetch("streams").each do |stream|
190
+ lines << " Stream #{display(stream.fetch("id"))}: #{display(stream.fetch("display_name"))} " \
191
+ "(#{display(stream.fetch("type"))})"
192
+ end
193
+ end
194
+ lines
195
+ end.join("\n")
196
+ end
197
+
198
+ def display(value)
199
+ Redaction.message(value)
200
+ end
201
+
202
+ def safe_human_value(value)
203
+ case value
204
+ when Hash
205
+ value.to_h { |key, child| [key, safe_human_value(child)] }
206
+ when Array
207
+ value.map { |child| safe_human_value(child) }
208
+ when String
209
+ display(value)
210
+ else
211
+ value
212
+ end
213
+ end
214
+
215
+ def write(message)
216
+ @out.puts message
217
+ CLI::SUCCESS
218
+ end
219
+ end
220
+ end
221
+ end