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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +47 -0
- data/CODE_OF_CONDUCT.md +10 -0
- data/CONTRIBUTING.md +67 -0
- data/LICENSE.txt +21 -0
- data/README.md +188 -0
- data/SECURITY.md +56 -0
- data/docs/api-support-matrix.md +50 -0
- data/docs/architecture.md +89 -0
- data/docs/authentication.md +86 -0
- data/docs/commands.md +112 -0
- data/docs/configuration-schema-v1.json +129 -0
- data/docs/configuration.md +167 -0
- data/docs/google-client-compatibility.md +59 -0
- data/docs/plan-format.md +89 -0
- data/docs/plan-schema-v1.json +224 -0
- data/docs/rails.md +73 -0
- data/docs/reports.md +124 -0
- data/docs/safety.md +93 -0
- data/docs/troubleshooting.md +103 -0
- data/exe/analytics-ops +6 -0
- data/lib/analytics_ops/applier.rb +52 -0
- data/lib/analytics_ops/canonical.rb +65 -0
- data/lib/analytics_ops/cli.rb +427 -0
- data/lib/analytics_ops/clients/admin.rb +446 -0
- data/lib/analytics_ops/clients/data.rb +314 -0
- data/lib/analytics_ops/configuration/document.rb +25 -0
- data/lib/analytics_ops/configuration/loader.rb +76 -0
- data/lib/analytics_ops/configuration/schema.rb +107 -0
- data/lib/analytics_ops/configuration/validator.rb +344 -0
- data/lib/analytics_ops/configuration.rb +19 -0
- data/lib/analytics_ops/desired_state.rb +40 -0
- data/lib/analytics_ops/errors.rb +31 -0
- data/lib/analytics_ops/plan.rb +551 -0
- data/lib/analytics_ops/planner.rb +233 -0
- data/lib/analytics_ops/rails/railtie.rb +65 -0
- data/lib/analytics_ops/rails.rb +5 -0
- data/lib/analytics_ops/redaction.rb +46 -0
- data/lib/analytics_ops/reports/catalog.rb +100 -0
- data/lib/analytics_ops/reports/definition.rb +226 -0
- data/lib/analytics_ops/reports/result.rb +90 -0
- data/lib/analytics_ops/reports.rb +13 -0
- data/lib/analytics_ops/resources.rb +79 -0
- data/lib/analytics_ops/snapshot.rb +45 -0
- data/lib/analytics_ops/version.rb +5 -0
- data/lib/analytics_ops/workspace.rb +212 -0
- data/lib/analytics_ops.rb +21 -0
- data/lib/generators/analytics_ops/install_generator.rb +23 -0
- data/lib/generators/analytics_ops/templates/analytics-ops +9 -0
- data/lib/generators/analytics_ops/templates/analytics_ops.yml +23 -0
- data/sig/analytics_ops.rbs +395 -0
- 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
|