analytics_ops 0.2.0 → 0.3.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 +108 -0
- data/CONTRIBUTING.md +1 -0
- data/README.md +141 -13
- data/SECURITY.md +11 -2
- data/docs/ai-connections.md +192 -0
- data/docs/api-support-matrix.md +25 -8
- data/docs/architecture.md +78 -17
- data/docs/authentication.md +53 -3
- data/docs/bigquery.md +128 -0
- data/docs/commands.md +133 -12
- data/docs/configuration-schema-v1.json +608 -50
- data/docs/configuration.md +118 -5
- data/docs/google-client-compatibility.md +21 -1
- data/docs/governance.md +93 -0
- data/docs/health.md +82 -0
- data/docs/live-smoke-test.md +13 -10
- data/docs/rails.md +25 -9
- data/docs/reports.md +165 -3
- data/docs/safety.md +37 -5
- data/docs/troubleshooting.md +81 -7
- data/lib/analytics_ops/bigquery/definition.rb +173 -0
- data/lib/analytics_ops/bigquery.rb +4 -0
- data/lib/analytics_ops/cli/local_commands.rb +80 -0
- data/lib/analytics_ops/cli/options.rb +282 -9
- data/lib/analytics_ops/cli/presenter.rb +263 -28
- data/lib/analytics_ops/cli/presenter_csv.rb +92 -0
- data/lib/analytics_ops/cli/reporting_commands.rb +129 -0
- data/lib/analytics_ops/cli/setup_commands.rb +91 -0
- data/lib/analytics_ops/cli.rb +121 -44
- data/lib/analytics_ops/clients/admin.rb +28 -2
- data/lib/analytics_ops/clients/admin_governance.rb +95 -0
- data/lib/analytics_ops/clients/admin_governance_normalization.rb +90 -0
- data/lib/analytics_ops/clients/admin_resource_normalization.rb +61 -0
- data/lib/analytics_ops/clients/bigquery.rb +313 -0
- data/lib/analytics_ops/clients/data.rb +147 -54
- data/lib/analytics_ops/clients/data_result_normalization.rb +55 -0
- data/lib/analytics_ops/clients/error_translation.rb +13 -1
- data/lib/analytics_ops/configuration/schema.rb +175 -1
- data/lib/analytics_ops/configuration/validator.rb +45 -7
- data/lib/analytics_ops/configuration/validator_experimental.rb +136 -0
- data/lib/analytics_ops/configuration/validator_reporting.rb +93 -0
- data/lib/analytics_ops/configuration/writer.rb +112 -7
- data/lib/analytics_ops/desired_state.rb +28 -3
- data/lib/analytics_ops/errors.rb +33 -1
- data/lib/analytics_ops/funnels.rb +58 -0
- data/lib/analytics_ops/governance.rb +363 -0
- data/lib/analytics_ops/health.rb +310 -0
- data/lib/analytics_ops/mcp_server/custom_report_tool.rb +101 -0
- data/lib/analytics_ops/mcp_server/discovery_tools.rb +79 -0
- data/lib/analytics_ops/mcp_server/experimental_tools.rb +89 -0
- data/lib/analytics_ops/mcp_server/health_tools.rb +80 -0
- data/lib/analytics_ops/mcp_server/overview_tools.rb +49 -0
- data/lib/analytics_ops/mcp_server/standard_report_tool.rb +53 -0
- data/lib/analytics_ops/mcp_server.rb +402 -0
- data/lib/analytics_ops/portfolio.rb +216 -0
- data/lib/analytics_ops/rails/railtie.rb +19 -8
- data/lib/analytics_ops/redaction.rb +10 -6
- data/lib/analytics_ops/reports/csv_export.rb +91 -0
- data/lib/analytics_ops/reports/definition.rb +152 -14
- data/lib/analytics_ops/reports/metadata.rb +158 -0
- data/lib/analytics_ops/reports/period.rb +104 -0
- data/lib/analytics_ops/reports/result.rb +8 -3
- data/lib/analytics_ops/reports.rb +3 -0
- data/lib/analytics_ops/service_account.rb +321 -29
- data/lib/analytics_ops/setup.rb +31 -7
- data/lib/analytics_ops/version.rb +1 -1
- data/lib/analytics_ops/workspace.rb +222 -10
- data/lib/analytics_ops.rb +7 -2
- data/lib/generators/analytics_ops/templates/analytics_ops.yml +1 -1
- data/sig/analytics_ops.rbs +494 -9
- metadata +60 -1
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "presenter_csv"
|
|
4
|
+
|
|
3
5
|
module AnalyticsOps
|
|
4
6
|
class CLI
|
|
5
7
|
# Formats gem-owned values for terminal and automation consumers.
|
|
6
8
|
class Presenter
|
|
9
|
+
include PresenterCSV
|
|
10
|
+
|
|
7
11
|
OVERVIEW_LABELS = {
|
|
8
12
|
"overview_totals" => "Totals",
|
|
9
13
|
"overview_trend" => "Daily trend",
|
|
@@ -18,6 +22,11 @@ module AnalyticsOps
|
|
|
18
22
|
end
|
|
19
23
|
|
|
20
24
|
def render(value, status: CLI::SUCCESS)
|
|
25
|
+
if value.is_a?(Reports::ExportResult)
|
|
26
|
+
@format == "json" ? @out.write(json(value)) : @out.puts(human(value))
|
|
27
|
+
return status
|
|
28
|
+
end
|
|
29
|
+
|
|
21
30
|
case @format
|
|
22
31
|
when "json"
|
|
23
32
|
@out.write(json(value))
|
|
@@ -41,6 +50,41 @@ module AnalyticsOps
|
|
|
41
50
|
write(lines.join("\n"))
|
|
42
51
|
end
|
|
43
52
|
|
|
53
|
+
def render_connections(connections)
|
|
54
|
+
return render(connections) unless @format == "human"
|
|
55
|
+
if connections.empty?
|
|
56
|
+
return write("No Google connections saved. Run `analytics-ops setup --service-account PATH`.")
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
lines = connections.map do |connection|
|
|
60
|
+
status = connection.fetch("available") ? "ready" : "key unavailable"
|
|
61
|
+
in_use = connection.fetch("in_use") ? " · in use" : ""
|
|
62
|
+
"#{display(connection.fetch("name"))}: #{status}#{in_use}"
|
|
63
|
+
end
|
|
64
|
+
write(lines.join("\n"))
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def render_profiles(profiles)
|
|
68
|
+
return render(profiles) unless @format == "human"
|
|
69
|
+
|
|
70
|
+
lines = profiles.map do |profile|
|
|
71
|
+
marker = profile.fetch("selected") ? "*" : " "
|
|
72
|
+
connection = profile["connection"] || "not connected"
|
|
73
|
+
"#{marker} #{display(profile.fetch("name"))}: property #{display(profile.fetch("property_id"))} " \
|
|
74
|
+
"· connection #{display(connection)}"
|
|
75
|
+
end
|
|
76
|
+
write(lines.join("\n"))
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def render_selection(selection)
|
|
80
|
+
return render(selection) unless @format == "human"
|
|
81
|
+
|
|
82
|
+
write(
|
|
83
|
+
"Using profile #{display(selection.fetch("profile"))} with connection " \
|
|
84
|
+
"#{display(selection.fetch("connection"))}."
|
|
85
|
+
)
|
|
86
|
+
end
|
|
87
|
+
|
|
44
88
|
def json(value)
|
|
45
89
|
payload = serializable(value)
|
|
46
90
|
"#{JSON.pretty_generate(Canonical.normalize(payload))}\n"
|
|
@@ -64,16 +108,6 @@ module AnalyticsOps
|
|
|
64
108
|
|
|
65
109
|
private
|
|
66
110
|
|
|
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
111
|
def human_plan_change(change, detailed:)
|
|
78
112
|
lines = [
|
|
79
113
|
" #{display(change.operation).upcase.ljust(6)} #{display(change.resource_type)} " \
|
|
@@ -81,22 +115,9 @@ module AnalyticsOps
|
|
|
81
115
|
]
|
|
82
116
|
return lines unless detailed
|
|
83
117
|
|
|
84
|
-
lines << " before: #{
|
|
85
|
-
lines << " after: #{
|
|
86
|
-
lines << " 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("\"", "\"\"")}\""
|
|
118
|
+
lines << " before: #{complete_display(JSON.generate(Canonical.normalize(change.before)))}"
|
|
119
|
+
lines << " after: #{complete_display(JSON.generate(Canonical.normalize(change.after)))}"
|
|
120
|
+
lines << " rollback: #{complete_display(change.rollback)}"
|
|
100
121
|
end
|
|
101
122
|
|
|
102
123
|
def serializable(value)
|
|
@@ -123,10 +144,46 @@ module AnalyticsOps
|
|
|
123
144
|
"#{value.converged ? "Converged" : "Drift found"}: #{value.plan.changes.length} planned changes"
|
|
124
145
|
when Applier::Result
|
|
125
146
|
"Apply #{value.status}: #{value.applied.length} changes completed"
|
|
147
|
+
else
|
|
148
|
+
human_analytics_value(value)
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def human_analytics_value(value)
|
|
153
|
+
case value
|
|
126
154
|
when Reports::Result
|
|
127
155
|
human_report(value)
|
|
128
156
|
when Reports::OverviewResult
|
|
129
157
|
human_overview(value)
|
|
158
|
+
when Reports::FieldList
|
|
159
|
+
human_fields(value)
|
|
160
|
+
when Reports::RecipeList
|
|
161
|
+
human_recipes(value)
|
|
162
|
+
when Reports::Compatibility
|
|
163
|
+
human_compatibility(value)
|
|
164
|
+
when Reports::ExportResult
|
|
165
|
+
"Exported #{value.rows} rows to #{complete_display(value.path)}"
|
|
166
|
+
else
|
|
167
|
+
human_operations_value(value)
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def human_operations_value(value)
|
|
172
|
+
case value
|
|
173
|
+
when Health::Result
|
|
174
|
+
human_health(value)
|
|
175
|
+
when Governance::Result
|
|
176
|
+
human_governance(value)
|
|
177
|
+
when Governance::ChangeHistory
|
|
178
|
+
human_change_history(value)
|
|
179
|
+
when Governance::AccessReport
|
|
180
|
+
human_access_report(value)
|
|
181
|
+
when BigQuery::Result
|
|
182
|
+
human_bigquery(value)
|
|
183
|
+
when Portfolio::Result
|
|
184
|
+
human_portfolio(value)
|
|
185
|
+
when Portfolio::Collection
|
|
186
|
+
human_portfolio_collection(value)
|
|
130
187
|
when Array
|
|
131
188
|
human_discovery(value)
|
|
132
189
|
else
|
|
@@ -134,6 +191,155 @@ module AnalyticsOps
|
|
|
134
191
|
end
|
|
135
192
|
end
|
|
136
193
|
|
|
194
|
+
def human_bigquery(value)
|
|
195
|
+
lines = [
|
|
196
|
+
"Experimental BigQuery report #{display(value.name)} — #{value.rows.length} rows · " \
|
|
197
|
+
"#{value.bytes_processed} bytes processed"
|
|
198
|
+
]
|
|
199
|
+
return (lines << "No rows returned").join("\n") if value.rows.empty?
|
|
200
|
+
|
|
201
|
+
widths = value.headers.to_h do |header|
|
|
202
|
+
values = value.rows.map { |row| display(row.fetch(header)) }
|
|
203
|
+
[header, ([header.length] + values.map(&:length)).max.clamp(1, 60)]
|
|
204
|
+
end
|
|
205
|
+
lines << table_row(value.headers, widths)
|
|
206
|
+
lines << value.headers.map { |header| "-" * widths.fetch(header) }.join("-+-")
|
|
207
|
+
value.rows.each do |row|
|
|
208
|
+
lines << table_row(value.headers.map { |header| row.fetch(header) }, widths, headers: value.headers)
|
|
209
|
+
end
|
|
210
|
+
lines.join("\n")
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def human_governance(result)
|
|
214
|
+
snapshot = result.snapshot
|
|
215
|
+
lines = [
|
|
216
|
+
"Experimental governance audit for property #{display(snapshot.property_id)}",
|
|
217
|
+
"#{snapshot.stream_settings.length} web streams · #{snapshot.channel_groups.length} channel groups · " \
|
|
218
|
+
"#{snapshot.calculated_metrics.length} calculated metrics · #{snapshot.bigquery_links.length} BigQuery links"
|
|
219
|
+
]
|
|
220
|
+
if result.findings.empty?
|
|
221
|
+
lines << "OK No configured governance mismatch found"
|
|
222
|
+
else
|
|
223
|
+
result.findings.each do |finding|
|
|
224
|
+
status = display(finding.severity).upcase.ljust(8)
|
|
225
|
+
lines << "#{status} #{display(finding.code)}: #{display(finding.message)}"
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
lines.join("\n")
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def human_change_history(value)
|
|
232
|
+
heading = "Experimental change history for property #{display(value.property_id)}"
|
|
233
|
+
lines = ["#{heading} — #{value.events.length} events"]
|
|
234
|
+
value.events.each do |event|
|
|
235
|
+
lines << "#{display(event["change_time"] || "unknown time")} · " \
|
|
236
|
+
"#{display(event["actor_type"] || "unknown actor")} · #{Array(event["changes"]).length} changes"
|
|
237
|
+
Array(event["changes"]).each do |change|
|
|
238
|
+
lines << " #{display(change.fetch("action")).upcase.ljust(10)} #{display(change.fetch("resource"))}"
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
lines.join("\n")
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
def human_access_report(value)
|
|
245
|
+
value.dimension_headers
|
|
246
|
+
value.metric_headers
|
|
247
|
+
report = Reports::Result.new(
|
|
248
|
+
name: "access_report",
|
|
249
|
+
kind: "standard",
|
|
250
|
+
dimension_headers: value.dimension_headers,
|
|
251
|
+
metric_headers: value.metric_headers,
|
|
252
|
+
rows: value.rows,
|
|
253
|
+
row_count: value.row_count,
|
|
254
|
+
metadata: { "quota" => value.quota }
|
|
255
|
+
)
|
|
256
|
+
[
|
|
257
|
+
"Experimental aggregate Data API quota access for property #{display(value.property_id)}",
|
|
258
|
+
*human_report_table(report)
|
|
259
|
+
]
|
|
260
|
+
.join("\n")
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
def human_portfolio_collection(collection)
|
|
264
|
+
lines = ["Portfolio #{display(collection.name)} — #{collection.entries.length} properties"]
|
|
265
|
+
collection.entries.each do |entry|
|
|
266
|
+
lines.concat(human_portfolio_entry(collection.kind, entry))
|
|
267
|
+
end
|
|
268
|
+
lines.join("\n")
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
def human_portfolio_entry(kind, entry)
|
|
272
|
+
identity = portfolio_identity(entry)
|
|
273
|
+
return ["ERROR #{identity}: #{display(entry.error.fetch("message"))}"] if entry.status == "error"
|
|
274
|
+
return [human_portfolio_health_entry(identity, entry)] if kind == "health"
|
|
275
|
+
|
|
276
|
+
rows = entry.result.fetch("rows").length
|
|
277
|
+
["OK #{identity}: #{rows} returned rows"] +
|
|
278
|
+
portfolio_report_table(entry.result).map { |line| " #{line}" }
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
def portfolio_identity(entry)
|
|
282
|
+
identity = "#{display(entry.profile)} (property #{display(entry.property_id)})"
|
|
283
|
+
labels = entry.labels.map { |name, value| "#{display(name)}=#{display(value)}" }.join(", ")
|
|
284
|
+
labels.empty? ? identity : "#{identity} · #{labels}"
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
def human_portfolio_health_entry(identity, entry)
|
|
288
|
+
status = entry.result.fetch("healthy") ? "OK" : "ISSUES"
|
|
289
|
+
findings = entry.result.fetch("findings").count { |finding| finding.fetch("severity") != "ok" }
|
|
290
|
+
"#{status.ljust(8)} #{identity}: #{findings} findings"
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
def portfolio_report_table(payload)
|
|
294
|
+
result = Reports::Result.new(
|
|
295
|
+
name: payload.fetch("name"),
|
|
296
|
+
kind: payload.fetch("kind"),
|
|
297
|
+
dimension_headers: payload.fetch("dimension_headers"),
|
|
298
|
+
metric_headers: payload.fetch("metric_headers"),
|
|
299
|
+
rows: payload.fetch("rows"),
|
|
300
|
+
row_count: payload.fetch("row_count"),
|
|
301
|
+
metadata: payload.fetch("metadata")
|
|
302
|
+
)
|
|
303
|
+
human_report_table(result)
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
def human_health(result)
|
|
307
|
+
current = result.summary.fetch("current")
|
|
308
|
+
lines = [
|
|
309
|
+
"Analytics health for #{display(result.profile)} / property #{display(result.property_id)}",
|
|
310
|
+
"Current: #{display(current.fetch("sessions"))} sessions · " \
|
|
311
|
+
"#{display(current.fetch("activeUsers"))} users · #{display(current.fetch("keyEvents"))} key events"
|
|
312
|
+
]
|
|
313
|
+
result.findings.each do |finding|
|
|
314
|
+
lines << "#{display(finding.severity).upcase.ljust(8)} #{display(finding.code)}: #{display(finding.message)}"
|
|
315
|
+
end
|
|
316
|
+
lines.join("\n")
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
def human_fields(list)
|
|
320
|
+
return "No matching report fields found" if list.fields.empty?
|
|
321
|
+
|
|
322
|
+
list.fields.map do |field|
|
|
323
|
+
custom = field.custom_definition ? " · custom" : ""
|
|
324
|
+
name = display(field.ui_name || field.api_name)
|
|
325
|
+
"#{display(field.kind).ljust(9)} #{display(field.api_name)} — #{name}#{custom}"
|
|
326
|
+
end.join("\n")
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
def human_recipes(list)
|
|
330
|
+
list.recipes.map do |recipe|
|
|
331
|
+
"#{display(recipe.name).ljust(32)} #{display(recipe.kind).ljust(8)} #{display(recipe.source)}"
|
|
332
|
+
end.join("\n")
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
def human_compatibility(value)
|
|
336
|
+
lines = ["Report #{display(value.report_name)} is #{value.compatible ? "compatible" : "not compatible"}"]
|
|
337
|
+
(value.dimensions + value.metrics).each do |entry|
|
|
338
|
+
lines << " #{display(entry.fetch("compatibility")).upcase.ljust(14)} #{display(entry.fetch("api_name"))}"
|
|
339
|
+
end
|
|
340
|
+
lines.join("\n")
|
|
341
|
+
end
|
|
342
|
+
|
|
137
343
|
def human_doctor(value)
|
|
138
344
|
value.checks.map do |check|
|
|
139
345
|
status = display(check.fetch("status")).upcase.ljust(11)
|
|
@@ -149,7 +355,7 @@ module AnalyticsOps
|
|
|
149
355
|
end
|
|
150
356
|
|
|
151
357
|
def human_overview(overview)
|
|
152
|
-
lines = ["Overview for property #{display(overview.property_id)}
|
|
358
|
+
lines = ["Overview for property #{display(overview.property_id)}"]
|
|
153
359
|
overview.reports.each do |report|
|
|
154
360
|
lines << ""
|
|
155
361
|
lines << OVERVIEW_LABELS.fetch(report.name, report.name)
|
|
@@ -158,6 +364,22 @@ module AnalyticsOps
|
|
|
158
364
|
lines.join("\n")
|
|
159
365
|
end
|
|
160
366
|
|
|
367
|
+
def human_portfolio(portfolio)
|
|
368
|
+
headers = %w[profile property_id period active_users sessions key_events]
|
|
369
|
+
rows = portfolio.entries.map(&:to_h)
|
|
370
|
+
widths = headers.to_h do |header|
|
|
371
|
+
values = rows.map { |row| display(row.fetch(header)) }
|
|
372
|
+
[header, ([header.length] + values.map(&:length)).max.clamp(1, 60)]
|
|
373
|
+
end
|
|
374
|
+
lines = ["Portfolio overview — #{portfolio.entries.length} property/period rows"]
|
|
375
|
+
lines << table_row(headers, widths)
|
|
376
|
+
lines << headers.map { |header| "-" * widths.fetch(header) }.join("-+-")
|
|
377
|
+
rows.each do |row|
|
|
378
|
+
lines << table_row(headers.map { |header| row.fetch(header) }, widths, headers:)
|
|
379
|
+
end
|
|
380
|
+
lines.join("\n")
|
|
381
|
+
end
|
|
382
|
+
|
|
161
383
|
def human_report_table(result)
|
|
162
384
|
return ["No rows returned"] if result.rows.empty?
|
|
163
385
|
|
|
@@ -175,10 +397,19 @@ module AnalyticsOps
|
|
|
175
397
|
|
|
176
398
|
def table_row(values, widths, headers: values)
|
|
177
399
|
values.zip(headers).map do |value, header|
|
|
178
|
-
|
|
400
|
+
truncate_cell(complete_display(value), widths.fetch(header)).ljust(widths.fetch(header))
|
|
179
401
|
end.join(" | ")
|
|
180
402
|
end
|
|
181
403
|
|
|
404
|
+
def truncate_cell(value, width)
|
|
405
|
+
return value if value.length <= width
|
|
406
|
+
return "…" if width == 1
|
|
407
|
+
|
|
408
|
+
left = (width - 1) / 2
|
|
409
|
+
right = width - left - 1
|
|
410
|
+
"#{value.slice(0, left)}…#{value.slice(-right, right)}"
|
|
411
|
+
end
|
|
412
|
+
|
|
182
413
|
def human_discovery(accounts)
|
|
183
414
|
return "No accessible Google Analytics accounts found" if accounts.empty?
|
|
184
415
|
|
|
@@ -199,6 +430,10 @@ module AnalyticsOps
|
|
|
199
430
|
Redaction.message(value)
|
|
200
431
|
end
|
|
201
432
|
|
|
433
|
+
def complete_display(value)
|
|
434
|
+
Redaction.text(value)
|
|
435
|
+
end
|
|
436
|
+
|
|
202
437
|
def safe_human_value(value)
|
|
203
438
|
case value
|
|
204
439
|
when Hash
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AnalyticsOps
|
|
4
|
+
class CLI
|
|
5
|
+
# CSV formatting isolated from the human and JSON presenter paths.
|
|
6
|
+
module PresenterCSV
|
|
7
|
+
private
|
|
8
|
+
|
|
9
|
+
def csv(value)
|
|
10
|
+
return portfolio_csv(value) if value.is_a?(Portfolio::Collection) && value.kind == "report"
|
|
11
|
+
return tabular_csv(value.headers, value.rows) if value.is_a?(BigQuery::Result)
|
|
12
|
+
unless value.is_a?(Reports::Result)
|
|
13
|
+
raise OptionParser::InvalidArgument, "CSV output is only valid for report results"
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
tabular_csv(value.headers, value.rows)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def tabular_csv(headers, rows)
|
|
20
|
+
lines = [headers.map { |header| csv_cell(header) }]
|
|
21
|
+
rows.each { |row| lines << headers.map { |header| csv_cell(row.fetch(header)) } }
|
|
22
|
+
encode_csv(lines)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def portfolio_csv(value)
|
|
26
|
+
successful = successful_portfolio_entries(value)
|
|
27
|
+
headers = shared_report_headers(successful)
|
|
28
|
+
label_names = successful.flat_map { |entry| entry.labels.keys }.uniq.sort
|
|
29
|
+
output_headers = portfolio_headers(headers, label_names)
|
|
30
|
+
lines = [output_headers.map { |header| csv_cell(header) }]
|
|
31
|
+
successful.each do |entry|
|
|
32
|
+
lines.concat(portfolio_entry_rows(entry, headers, label_names))
|
|
33
|
+
end
|
|
34
|
+
encode_csv(lines)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def successful_portfolio_entries(value)
|
|
38
|
+
unless value.success?
|
|
39
|
+
raise RemoteError, "Portfolio CSV requires every property to succeed; use JSON to inspect isolated failures"
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
value.entries.select { |entry| entry.status == "ok" }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def shared_report_headers(entries)
|
|
46
|
+
reports = entries.map(&:result)
|
|
47
|
+
headers = Array(reports.first&.fetch("dimension_headers", []))
|
|
48
|
+
headers += Array(reports.first&.fetch("metric_headers", []))
|
|
49
|
+
same = reports.all? do |report|
|
|
50
|
+
report.fetch("dimension_headers") + report.fetch("metric_headers") == headers
|
|
51
|
+
end
|
|
52
|
+
raise RemoteError, "Portfolio report headers differ across profiles" unless same
|
|
53
|
+
|
|
54
|
+
headers
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def portfolio_headers(report_headers, label_names)
|
|
58
|
+
headers = %w[profile property_id] + label_names.map { |name| "label_#{name}" } + report_headers
|
|
59
|
+
unless headers.uniq.length == headers.length
|
|
60
|
+
raise RemoteError, "Portfolio CSV context columns conflict with report headers"
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
headers
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def portfolio_entry_rows(entry, headers, label_names)
|
|
67
|
+
labels = label_names.map { |name| entry.labels.fetch(name, "") }
|
|
68
|
+
entry.result.fetch("rows").map do |row|
|
|
69
|
+
values = [entry.profile, entry.property_id] + labels + headers.map { |header| row.fetch(header) }
|
|
70
|
+
values.map { |value| csv_cell(value) }
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def encode_csv(lines)
|
|
75
|
+
"#{lines.map { |line| line.map { |cell| csv_field(cell) }.join(",") }.join("\n")}\n"
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def csv_cell(value)
|
|
79
|
+
string = value.to_s.gsub(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/, "?")
|
|
80
|
+
dangerous = string.match?(/\A(?:[\p{Space}\uFEFF]*[=+\-@]|[\t\r\n])/)
|
|
81
|
+
dangerous ? "'#{string}" : string
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def csv_field(value)
|
|
85
|
+
string = value.to_s
|
|
86
|
+
return string unless string.match?(/[",\r\n]/)
|
|
87
|
+
|
|
88
|
+
"\"#{string.gsub("\"", "\"\"")}\""
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AnalyticsOps
|
|
4
|
+
# Command-line consumer of the public Analytics Ops API.
|
|
5
|
+
class CLI
|
|
6
|
+
# Implements date-aware reporting and multi-property portfolio commands.
|
|
7
|
+
module ReportingCommands
|
|
8
|
+
private
|
|
9
|
+
|
|
10
|
+
def run_portfolio
|
|
11
|
+
portfolio = @portfolio_loader.call(
|
|
12
|
+
config: @options.fetch(:config),
|
|
13
|
+
store: service_account_store,
|
|
14
|
+
workspace_loader: @workspace_loader,
|
|
15
|
+
service_account_loader: @service_account_loader,
|
|
16
|
+
transport: @options.fetch(:transport),
|
|
17
|
+
timeout: @options[:timeout],
|
|
18
|
+
logger: operation_logger
|
|
19
|
+
)
|
|
20
|
+
ranges = report_date_ranges
|
|
21
|
+
concurrency = @options.fetch(:concurrency, 4)
|
|
22
|
+
case @arguments
|
|
23
|
+
when []
|
|
24
|
+
result = ranges ? portfolio.overview(date_ranges: ranges) : portfolio.overview
|
|
25
|
+
render(result)
|
|
26
|
+
when ["health"]
|
|
27
|
+
health_ranges = ranges && ensure_comparison(ranges)
|
|
28
|
+
result = if health_ranges
|
|
29
|
+
portfolio.health(date_ranges: health_ranges, concurrency:)
|
|
30
|
+
else
|
|
31
|
+
portfolio.health(concurrency:)
|
|
32
|
+
end
|
|
33
|
+
render(result, status: result.success? ? SUCCESS : DRIFT)
|
|
34
|
+
else
|
|
35
|
+
unless @arguments.length == 2 && @arguments.first == "report"
|
|
36
|
+
raise OptionParser::InvalidArgument, "portfolio accepts no arguments, `health`, or `report NAME`"
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
result = portfolio.report(@arguments.last, date_ranges: ranges, concurrency:)
|
|
40
|
+
render(result, status: result.success? ? SUCCESS : REMOTE_ERROR)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def render_workspace_overview(workspace)
|
|
45
|
+
ranges = report_date_ranges
|
|
46
|
+
render(ranges ? workspace.overview(date_ranges: ranges) : workspace.overview)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def render_workspace_report(workspace)
|
|
50
|
+
name = required_report_name!("report")
|
|
51
|
+
ranges = report_date_ranges
|
|
52
|
+
definition = name == "custom" ? @options.fetch(:custom_definition) : name
|
|
53
|
+
if @options.fetch(:all_rows)
|
|
54
|
+
return render(
|
|
55
|
+
workspace.export_report(
|
|
56
|
+
definition,
|
|
57
|
+
path: @options.fetch(:output),
|
|
58
|
+
date_ranges: ranges,
|
|
59
|
+
page_size: @options.fetch(:page_size, Workspace::DEFAULT_PAGE_SIZE),
|
|
60
|
+
max_rows: @options.fetch(:max_rows, Workspace::MAX_EXPORT_ROWS)
|
|
61
|
+
)
|
|
62
|
+
)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
render(ranges ? workspace.report(definition, date_ranges: ranges) : workspace.report(definition))
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def render_workspace_fields(workspace)
|
|
69
|
+
raise OptionParser::InvalidArgument, "fields accepts at most one SEARCH term" if @arguments.length > 1
|
|
70
|
+
|
|
71
|
+
render(workspace.report_metadata(query: @arguments.first, kind: @options[:field_kind]))
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def render_workspace_compatibility(workspace)
|
|
75
|
+
name = required_report_name!("compatibility")
|
|
76
|
+
render(workspace.report_compatibility(name))
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def render_workspace_health(workspace)
|
|
80
|
+
ranges = report_date_ranges
|
|
81
|
+
result = ranges ? workspace.health(date_ranges: ensure_comparison(ranges)) : workspace.health
|
|
82
|
+
render(result, status: result.healthy? ? SUCCESS : DRIFT)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def render_workspace_raw_report(workspace)
|
|
86
|
+
name = required_report_name!("raw")
|
|
87
|
+
ranges = report_date_ranges
|
|
88
|
+
render(
|
|
89
|
+
workspace.raw_report(
|
|
90
|
+
name,
|
|
91
|
+
date_ranges: ranges,
|
|
92
|
+
limit: @options.fetch(:limit, 1_000)
|
|
93
|
+
)
|
|
94
|
+
)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def render_workspace_funnel(workspace)
|
|
98
|
+
name = required_report_name!("funnel")
|
|
99
|
+
ranges = report_date_ranges
|
|
100
|
+
render(workspace.funnel(name, date_ranges: ranges))
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def ensure_comparison(ranges)
|
|
104
|
+
return ranges if ranges.length == 2
|
|
105
|
+
|
|
106
|
+
selected = ranges.fetch(0)
|
|
107
|
+
Reports::Period.resolve(
|
|
108
|
+
start_date: selected.fetch("start_date"),
|
|
109
|
+
end_date: selected.fetch("end_date"),
|
|
110
|
+
compare: true
|
|
111
|
+
)
|
|
112
|
+
rescue InvalidRequestError
|
|
113
|
+
days = @options[:last_days] || Reports::Period::DEFAULT_DAYS
|
|
114
|
+
Reports::Period.resolve(last_days: days, compare: true)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def report_date_ranges
|
|
118
|
+
Reports::Period.resolve(
|
|
119
|
+
last_days: @options[:last_days],
|
|
120
|
+
start_date: @options[:start_date],
|
|
121
|
+
end_date: @options[:end_date],
|
|
122
|
+
compare: @options.fetch(:compare)
|
|
123
|
+
)
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
include ReportingCommands
|
|
128
|
+
end
|
|
129
|
+
end
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AnalyticsOps
|
|
4
|
+
# Command-line consumer of the public Analytics Ops API.
|
|
5
|
+
class CLI
|
|
6
|
+
# Implements safe, interactive and non-interactive Google setup.
|
|
7
|
+
module SetupCommands
|
|
8
|
+
private
|
|
9
|
+
|
|
10
|
+
def setup(connection, service_account, profile:)
|
|
11
|
+
result = run_setup(connection, service_account, profile:)
|
|
12
|
+
connection_name = remember_setup_connection(profile, service_account)
|
|
13
|
+
return render_setup_result(result, connection_name) if human?
|
|
14
|
+
|
|
15
|
+
render(result.to_h.merge("connection" => connection_name))
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def run_setup(connection, service_account, profile:)
|
|
19
|
+
Setup.new(
|
|
20
|
+
connection:,
|
|
21
|
+
config: @options.fetch(:config),
|
|
22
|
+
profile:,
|
|
23
|
+
property_id: @options[:property],
|
|
24
|
+
noninteractive: @options[:noninteractive],
|
|
25
|
+
warnings: service_account_warnings(service_account),
|
|
26
|
+
input: @input,
|
|
27
|
+
out: @out
|
|
28
|
+
).call
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def remember_setup_connection(profile, service_account)
|
|
32
|
+
connection_name = setup_connection_name(profile, service_account)
|
|
33
|
+
service_account_store.write(
|
|
34
|
+
service_account.path,
|
|
35
|
+
name: connection_name,
|
|
36
|
+
config: @options.fetch(:config),
|
|
37
|
+
profile:
|
|
38
|
+
)
|
|
39
|
+
connection_name
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def render_setup_result(result, connection_name)
|
|
43
|
+
action = if result.created?
|
|
44
|
+
"Created"
|
|
45
|
+
elsif result.updated?
|
|
46
|
+
"Updated"
|
|
47
|
+
else
|
|
48
|
+
"Using"
|
|
49
|
+
end
|
|
50
|
+
@out.puts "#{action} #{Redaction.message(result.config_path)}"
|
|
51
|
+
@out.puts "Connected #{Redaction.message(result.profile)} to " \
|
|
52
|
+
"#{Redaction.message(result.property.display_name)} " \
|
|
53
|
+
"(property #{Redaction.message(result.property.id)})."
|
|
54
|
+
@out.puts "Saved Google connection #{Redaction.message(connection_name)}."
|
|
55
|
+
result.warnings.each { |warning| @err.puts "Warning: #{Redaction.message(warning)}" }
|
|
56
|
+
@out.puts "Next: analytics-ops overview"
|
|
57
|
+
SUCCESS
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def setup_connection_name(profile, service_account)
|
|
61
|
+
return @options.fetch(:connection) if @options[:connection]
|
|
62
|
+
return new_connection_name(profile, service_account) if @options[:service_account]
|
|
63
|
+
return profile unless service_account_store.respond_to?(:resolve_connection_name)
|
|
64
|
+
|
|
65
|
+
service_account_store.resolve_connection_name(
|
|
66
|
+
config: @options.fetch(:config),
|
|
67
|
+
profile:
|
|
68
|
+
)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def new_connection_name(profile, service_account)
|
|
72
|
+
return profile unless service_account_store.respond_to?(:connection_name_for)
|
|
73
|
+
|
|
74
|
+
service_account_store.connection_name_for(
|
|
75
|
+
service_account.path,
|
|
76
|
+
preferred: profile,
|
|
77
|
+
config: @options.fetch(:config),
|
|
78
|
+
profile:
|
|
79
|
+
)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def service_account_warnings(service_account)
|
|
83
|
+
return [] unless service_account.respond_to?(:security_warnings)
|
|
84
|
+
|
|
85
|
+
service_account.security_warnings
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
include SetupCommands
|
|
90
|
+
end
|
|
91
|
+
end
|