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.
Files changed (72) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +108 -0
  3. data/CONTRIBUTING.md +1 -0
  4. data/README.md +141 -13
  5. data/SECURITY.md +11 -2
  6. data/docs/ai-connections.md +192 -0
  7. data/docs/api-support-matrix.md +25 -8
  8. data/docs/architecture.md +78 -17
  9. data/docs/authentication.md +53 -3
  10. data/docs/bigquery.md +128 -0
  11. data/docs/commands.md +133 -12
  12. data/docs/configuration-schema-v1.json +608 -50
  13. data/docs/configuration.md +118 -5
  14. data/docs/google-client-compatibility.md +21 -1
  15. data/docs/governance.md +93 -0
  16. data/docs/health.md +82 -0
  17. data/docs/live-smoke-test.md +13 -10
  18. data/docs/rails.md +25 -9
  19. data/docs/reports.md +165 -3
  20. data/docs/safety.md +37 -5
  21. data/docs/troubleshooting.md +81 -7
  22. data/lib/analytics_ops/bigquery/definition.rb +173 -0
  23. data/lib/analytics_ops/bigquery.rb +4 -0
  24. data/lib/analytics_ops/cli/local_commands.rb +80 -0
  25. data/lib/analytics_ops/cli/options.rb +282 -9
  26. data/lib/analytics_ops/cli/presenter.rb +263 -28
  27. data/lib/analytics_ops/cli/presenter_csv.rb +92 -0
  28. data/lib/analytics_ops/cli/reporting_commands.rb +129 -0
  29. data/lib/analytics_ops/cli/setup_commands.rb +91 -0
  30. data/lib/analytics_ops/cli.rb +121 -44
  31. data/lib/analytics_ops/clients/admin.rb +28 -2
  32. data/lib/analytics_ops/clients/admin_governance.rb +95 -0
  33. data/lib/analytics_ops/clients/admin_governance_normalization.rb +90 -0
  34. data/lib/analytics_ops/clients/admin_resource_normalization.rb +61 -0
  35. data/lib/analytics_ops/clients/bigquery.rb +313 -0
  36. data/lib/analytics_ops/clients/data.rb +147 -54
  37. data/lib/analytics_ops/clients/data_result_normalization.rb +55 -0
  38. data/lib/analytics_ops/clients/error_translation.rb +13 -1
  39. data/lib/analytics_ops/configuration/schema.rb +175 -1
  40. data/lib/analytics_ops/configuration/validator.rb +45 -7
  41. data/lib/analytics_ops/configuration/validator_experimental.rb +136 -0
  42. data/lib/analytics_ops/configuration/validator_reporting.rb +93 -0
  43. data/lib/analytics_ops/configuration/writer.rb +112 -7
  44. data/lib/analytics_ops/desired_state.rb +28 -3
  45. data/lib/analytics_ops/errors.rb +33 -1
  46. data/lib/analytics_ops/funnels.rb +58 -0
  47. data/lib/analytics_ops/governance.rb +363 -0
  48. data/lib/analytics_ops/health.rb +310 -0
  49. data/lib/analytics_ops/mcp_server/custom_report_tool.rb +101 -0
  50. data/lib/analytics_ops/mcp_server/discovery_tools.rb +79 -0
  51. data/lib/analytics_ops/mcp_server/experimental_tools.rb +89 -0
  52. data/lib/analytics_ops/mcp_server/health_tools.rb +80 -0
  53. data/lib/analytics_ops/mcp_server/overview_tools.rb +49 -0
  54. data/lib/analytics_ops/mcp_server/standard_report_tool.rb +53 -0
  55. data/lib/analytics_ops/mcp_server.rb +402 -0
  56. data/lib/analytics_ops/portfolio.rb +216 -0
  57. data/lib/analytics_ops/rails/railtie.rb +19 -8
  58. data/lib/analytics_ops/redaction.rb +10 -6
  59. data/lib/analytics_ops/reports/csv_export.rb +91 -0
  60. data/lib/analytics_ops/reports/definition.rb +152 -14
  61. data/lib/analytics_ops/reports/metadata.rb +158 -0
  62. data/lib/analytics_ops/reports/period.rb +104 -0
  63. data/lib/analytics_ops/reports/result.rb +8 -3
  64. data/lib/analytics_ops/reports.rb +3 -0
  65. data/lib/analytics_ops/service_account.rb +321 -29
  66. data/lib/analytics_ops/setup.rb +31 -7
  67. data/lib/analytics_ops/version.rb +1 -1
  68. data/lib/analytics_ops/workspace.rb +222 -10
  69. data/lib/analytics_ops.rb +7 -2
  70. data/lib/generators/analytics_ops/templates/analytics_ops.yml +1 -1
  71. data/sig/analytics_ops.rbs +494 -9
  72. 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: #{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("\"", "\"\"")}\""
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)} — previous 28 complete days"]
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
- display(value).slice(0, widths.fetch(header)).ljust(widths.fetch(header))
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