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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +76 -0
- data/CONTRIBUTING.md +2 -1
- data/README.md +232 -41
- data/SECURITY.md +8 -8
- data/docs/api-support-matrix.md +9 -8
- data/docs/architecture.md +22 -4
- data/docs/authentication.md +112 -51
- data/docs/commands.md +71 -17
- data/docs/configuration-schema-v1.json +53 -7
- data/docs/configuration.md +33 -9
- data/docs/google-client-compatibility.md +10 -6
- data/docs/live-smoke-test.md +159 -0
- data/docs/plan-format.md +4 -0
- data/docs/plan-schema-v1.json +113 -22
- data/docs/rails.md +12 -3
- data/docs/reports.md +44 -6
- data/docs/safety.md +18 -6
- data/docs/troubleshooting.md +42 -13
- data/lib/analytics_ops/applier.rb +2 -1
- data/lib/analytics_ops/cli/options.rb +157 -0
- data/lib/analytics_ops/cli/presenter.rb +221 -0
- data/lib/analytics_ops/cli.rb +172 -201
- data/lib/analytics_ops/clients/admin.rb +130 -129
- data/lib/analytics_ops/clients/admin_normalization.rb +95 -0
- data/lib/analytics_ops/clients/data.rb +120 -63
- data/lib/analytics_ops/clients/error_translation.rb +45 -0
- data/lib/analytics_ops/configuration/document.rb +1 -1
- data/lib/analytics_ops/configuration/loader.rb +38 -5
- data/lib/analytics_ops/configuration/schema.rb +51 -8
- data/lib/analytics_ops/configuration/validator.rb +43 -8
- data/lib/analytics_ops/configuration/writer.rb +105 -0
- data/lib/analytics_ops/configuration.rb +1 -0
- data/lib/analytics_ops/connection.rb +93 -0
- data/lib/analytics_ops/desired_state.rb +2 -2
- data/lib/analytics_ops/plan.rb +94 -33
- data/lib/analytics_ops/planner.rb +10 -2
- data/lib/analytics_ops/rails/railtie.rb +13 -18
- data/lib/analytics_ops/redaction.rb +14 -9
- data/lib/analytics_ops/reports/catalog.rb +22 -5
- data/lib/analytics_ops/reports/definition.rb +74 -37
- data/lib/analytics_ops/reports/overview.rb +55 -0
- data/lib/analytics_ops/reports/overview_result.rb +54 -0
- data/lib/analytics_ops/reports/result.rb +38 -9
- data/lib/analytics_ops/reports.rb +2 -0
- data/lib/analytics_ops/resources.rb +2 -1
- data/lib/analytics_ops/service_account.rb +171 -0
- data/lib/analytics_ops/setup.rb +171 -0
- data/lib/analytics_ops/snapshot.rb +20 -5
- data/lib/analytics_ops/version.rb +1 -1
- data/lib/analytics_ops/workspace.rb +41 -12
- data/lib/analytics_ops.rb +4 -0
- data/lib/generators/analytics_ops/install_generator.rb +2 -0
- data/lib/generators/analytics_ops/templates/analytics_ops.yml +2 -15
- data/sig/analytics_ops.rbs +117 -10
- 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
|