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
@@ -23,15 +23,24 @@ module AnalyticsOps
23
23
  INTERRUPTED = 130
24
24
 
25
25
  COMMANDS = %w[
26
- setup properties doctor discover snapshot audit plan apply verify overview report realtime schema
26
+ setup connections profiles use properties doctor discover snapshot audit plan apply verify overview report
27
+ realtime portfolio fields reports compatibility health governance changes access raw funnel schema mcp
27
28
  ].freeze
28
29
  FORMATS = %w[human json csv].freeze
29
30
  LOG_LEVELS = %w[debug info warn error].freeze
30
- NO_ARGUMENT_COMMANDS = %w[setup properties doctor discover snapshot audit plan verify overview schema].freeze
31
+ NO_ARGUMENT_COMMANDS = %w[
32
+ setup connections profiles properties doctor discover snapshot audit plan verify overview health governance
33
+ changes access schema mcp
34
+ reports
35
+ ].freeze
31
36
  CONNECTION_COMMANDS = %w[setup properties discover].freeze
37
+ LOCAL_COMMANDS = %w[connections profiles use mcp].freeze
38
+ CORE_WORKSPACE_COMMANDS = %w[doctor snapshot audit plan apply verify].freeze
39
+ REPORTING_COMMANDS = %w[overview report realtime fields reports compatibility health].freeze
32
40
 
33
41
  def self.start(arguments, out: $stdout, err: $stderr, input: $stdin, workspace_loader: nil,
34
- connection_loader: nil, service_account_loader: nil, service_account_store: nil)
42
+ connection_loader: nil, service_account_loader: nil, service_account_store: nil,
43
+ mcp_server_loader: nil, portfolio_loader: nil)
35
44
  new(
36
45
  arguments,
37
46
  out:,
@@ -40,12 +49,14 @@ module AnalyticsOps
40
49
  workspace_loader:,
41
50
  connection_loader:,
42
51
  service_account_loader:,
43
- service_account_store:
52
+ service_account_store:,
53
+ mcp_server_loader:,
54
+ portfolio_loader:
44
55
  ).call
45
56
  end
46
57
 
47
58
  def initialize(arguments, out:, err:, input:, workspace_loader:, connection_loader:,
48
- service_account_loader:, service_account_store:)
59
+ service_account_loader:, service_account_store:, mcp_server_loader:, portfolio_loader:)
49
60
  @arguments = arguments.dup
50
61
  @json_requested = json_option_present?(@arguments)
51
62
  @out = out
@@ -55,6 +66,8 @@ module AnalyticsOps
55
66
  @connection_loader = connection_loader || method(:load_connection)
56
67
  @service_account_loader = service_account_loader || ServiceAccount.method(:load)
57
68
  @service_account_store = service_account_store
69
+ @mcp_server_loader = mcp_server_loader || method(:load_mcp_server)
70
+ @portfolio_loader = portfolio_loader || method(:load_portfolio)
58
71
  end
59
72
 
60
73
  def call
@@ -86,22 +99,25 @@ module AnalyticsOps
86
99
 
87
100
  def execute(command)
88
101
  return render(Configuration::SCHEMA, status: SUCCESS) if command == "schema"
102
+ return dispatch_local(command) if LOCAL_COMMANDS.include?(command)
103
+ return run_portfolio if command == "portfolio"
89
104
 
105
+ profile = resolved_profile
90
106
  if CONNECTION_COMMANDS.include?(command)
91
- service_account = load_service_account
107
+ service_account = load_service_account(profile:)
92
108
  connection = @connection_loader.call(
93
109
  service_account:,
94
110
  transport: @options.fetch(:transport),
95
111
  timeout: @options[:timeout],
96
112
  logger: operation_logger
97
113
  )
98
- return dispatch_connection(command, connection, service_account)
114
+ return dispatch_connection(command, connection, service_account, profile:)
99
115
  end
100
116
 
101
- service_account = load_service_account
117
+ service_account = load_service_account(profile:)
102
118
  workspace = @workspace_loader.call(
103
119
  config: @options.fetch(:config),
104
- profile: @options.fetch(:profile),
120
+ profile:,
105
121
  service_account:,
106
122
  transport: @options.fetch(:transport),
107
123
  timeout: @options[:timeout],
@@ -137,6 +153,13 @@ module AnalyticsOps
137
153
  end
138
154
 
139
155
  def dispatch(command, workspace)
156
+ return dispatch_core(command, workspace) if CORE_WORKSPACE_COMMANDS.include?(command)
157
+ return dispatch_reporting(command, workspace) if REPORTING_COMMANDS.include?(command)
158
+
159
+ dispatch_experimental(command, workspace)
160
+ end
161
+
162
+ def dispatch_core(command, workspace)
140
163
  case command
141
164
  when "doctor"
142
165
  result = workspace.doctor
@@ -153,48 +176,52 @@ module AnalyticsOps
153
176
  when "verify"
154
177
  verification = workspace.verify
155
178
  render(verification, status: verification.converged ? SUCCESS : DRIFT)
179
+ end
180
+ end
181
+
182
+ def dispatch_reporting(command, workspace)
183
+ case command
156
184
  when "overview"
157
- render(workspace.overview)
185
+ render_workspace_overview(workspace)
158
186
  when "report"
159
- render(workspace.report(required_report_name!("report")))
187
+ render_workspace_report(workspace)
160
188
  when "realtime"
161
189
  render(workspace.realtime(optional_realtime_name!))
190
+ when "fields"
191
+ render_workspace_fields(workspace)
192
+ when "reports"
193
+ render(workspace.report_recipes)
194
+ when "compatibility"
195
+ render_workspace_compatibility(workspace)
196
+ when "health"
197
+ render_workspace_health(workspace)
198
+ end
199
+ end
200
+
201
+ def dispatch_experimental(command, workspace)
202
+ case command
203
+ when "governance"
204
+ result = workspace.governance
205
+ render(result, status: result.compliant? ? SUCCESS : DRIFT)
206
+ when "changes"
207
+ render(workspace.change_history)
208
+ when "access"
209
+ render(workspace.access_report)
210
+ when "raw"
211
+ render_workspace_raw_report(workspace)
212
+ when "funnel"
213
+ render_workspace_funnel(workspace)
162
214
  end
163
215
  end
164
216
 
165
- def dispatch_connection(command, connection, service_account)
217
+ def dispatch_connection(command, connection, service_account, profile:)
166
218
  case command
167
219
  when "discover"
168
220
  render(connection.discover)
169
221
  when "properties"
170
222
  render_properties(connection.properties)
171
223
  when "setup"
172
- setup(connection, service_account)
173
- end
174
- end
175
-
176
- def setup(connection, service_account)
177
- result = Setup.new(
178
- connection:,
179
- config: @options.fetch(:config),
180
- profile: @options.fetch(:profile),
181
- property_id: @options[:property],
182
- noninteractive: @options[:noninteractive],
183
- input: @input,
184
- out: @out
185
- ).call
186
- service_account_store.write(service_account.path)
187
-
188
- if human?
189
- action = result.created? ? "Created" : "Using"
190
- @out.puts "#{action} #{Redaction.message(result.config_path)}"
191
- @out.puts "Connected #{Redaction.message(result.profile)} to " \
192
- "#{Redaction.message(result.property.display_name)} " \
193
- "(property #{Redaction.message(result.property.id)})."
194
- @out.puts "Next: analytics-ops overview"
195
- SUCCESS
196
- else
197
- render(result)
224
+ setup(connection, service_account, profile:)
198
225
  end
199
226
  end
200
227
 
@@ -248,13 +275,31 @@ module AnalyticsOps
248
275
  Connection.new(service_account:, transport:, timeout:, logger:)
249
276
  end
250
277
 
251
- def load_service_account
278
+ def load_mcp_server(**)
279
+ require_relative "mcp_server"
280
+ MCPServer.new(**)
281
+ end
282
+
283
+ def load_portfolio(**)
284
+ Portfolio.new(**)
285
+ end
286
+
287
+ def load_service_account(profile:)
252
288
  @service_account_loader.call(
253
289
  path: @options[:service_account],
254
- store: service_account_store
290
+ store: service_account_store,
291
+ connection: @options[:connection],
292
+ config: @options.fetch(:config),
293
+ profile:
255
294
  )
256
295
  end
257
296
 
297
+ def resolved_profile
298
+ return @options.fetch(:profile) if @options.fetch(:profile_explicit)
299
+
300
+ service_account_store.selected_profile(config: @options.fetch(:config)) || @options.fetch(:profile)
301
+ end
302
+
258
303
  def service_account_store
259
304
  @service_account_store ||= ServiceAccount::Store.new
260
305
  end
@@ -355,7 +400,11 @@ module AnalyticsOps
355
400
  Start here:
356
401
  setup Connect Google, choose a property, and create configuration
357
402
  overview Show a useful five-section summary for the selected property
403
+ portfolio Compare totals, reports, or health across configured properties
358
404
  properties List accessible accounts and properties without configuration
405
+ profiles List saved property profiles
406
+ use NAME Select a profile and its Google connection
407
+ connections List saved Google connections
359
408
 
360
409
  Read-only commands:
361
410
  doctor Validate local setup and Google API/property access
@@ -364,9 +413,20 @@ module AnalyticsOps
364
413
  audit Compare desired and remote state (exit 2 for drift)
365
414
  plan Generate a deterministic plan; use --output to save it
366
415
  verify Prove whether managed state converges
367
- report NAME Run a built-in standard report
416
+ report NAME Run a built-in, saved, or one-off custom standard report
417
+ reports List built-in and saved report recipes
418
+ fields Search property-aware dimensions and metrics
419
+ compatibility NAME
420
+ Check whether a report's fields work together
421
+ health Check traffic, events, data quality, quota, and config drift
422
+ governance Audit broader GA4 settings (experimental, read-only)
423
+ changes Read recent GA4 change history (experimental; edit scope)
424
+ access Read aggregate Data API quota access (experimental)
425
+ raw NAME Run an optional, cost-capped BigQuery recipe (experimental)
426
+ funnel NAME Run a configured, aggregate user journey (experimental)
368
427
  realtime Run realtime_events (or pass another realtime recipe)
369
428
  schema Print the version-1 configuration schema
429
+ mcp Start the strictly read-only AI connection
370
430
 
371
431
  Mutation command:
372
432
  apply FILE Apply only a saved, non-stale plan; prompts unless --yes
@@ -377,11 +437,25 @@ module AnalyticsOps
377
437
 
378
438
  Common options:
379
439
  -c, --config PATH Default: config/analytics_ops.yml
380
- -p, --profile NAME Default: production
381
- -f, --format FORMAT human, json, or report-only csv
440
+ -p, --profile NAME Override the selected profile
441
+ --connection NAME Override the profile's saved Google connection
442
+ -f, --format FORMAT human, json, or result-only csv
382
443
  --json Shortcut for --format json
383
444
  --csv Shortcut for --format csv
384
- -o, --output PATH Save a generated plan (plan only)
445
+ --last DAYS Use the previous number of complete days
446
+ --from DATE Report start date (use with --to)
447
+ --to DATE Report end date (use with --from)
448
+ --compare Include the equally long preceding period
449
+ -o, --output PATH Save a plan, or a bounded --all CSV report
450
+ --dimension NAME Repeatable field for report custom
451
+ --metric NAME Repeatable field for report custom
452
+ --where FIELD=VALUE Repeatable exact filter for report custom
453
+ --limit ROWS Row limit for report custom or raw
454
+ --all Stream bounded report pages to --output
455
+ --max-rows ROWS Hard limit for --all (max 1000000)
456
+ --page-size ROWS Rows per --all request (max 250000)
457
+ --kind KIND dimension or metric for fields
458
+ --concurrency COUNT Portfolio workers (1-8)
385
459
  --property ID Select a property without prompting (setup only)
386
460
  --service-account PATH Google service-account JSON key (setup only)
387
461
  --transport NAME grpc or rest
@@ -396,3 +470,6 @@ end
396
470
 
397
471
  require_relative "cli/presenter"
398
472
  require_relative "cli/options"
473
+ require_relative "cli/local_commands"
474
+ require_relative "cli/reporting_commands"
475
+ require_relative "cli/setup_commands"
@@ -3,12 +3,19 @@
3
3
  # Official Admin API boundary.
4
4
 
5
5
  require_relative "admin_normalization"
6
+ require_relative "admin_resource_normalization"
7
+ require_relative "admin_governance_normalization"
8
+ require_relative "admin_governance"
9
+ require "time"
6
10
 
7
11
  module AnalyticsOps
8
12
  module Clients
9
13
  # Narrow adapter around Google's generated Admin client.
10
14
  class Admin
11
15
  include AdminNormalization
16
+ include AdminResourceNormalization
17
+ include AdminGovernanceNormalization
18
+ include AdminGovernance
12
19
 
13
20
  PACKAGE_REQUIREMENT = Gem::Requirement.new("~> 0.8.0")
14
21
  RETENTION_TO_GOOGLE = {
@@ -30,7 +37,18 @@ module AnalyticsOps
30
37
  "data_retention" => :update_data_retention_settings,
31
38
  "key_events" => :create_key_event,
32
39
  "custom_dimensions" => :create_custom_dimension,
33
- "custom_metrics" => :create_custom_metric
40
+ "custom_metrics" => :create_custom_metric,
41
+ "enhanced_measurement_audit" => :get_enhanced_measurement_settings,
42
+ "data_redaction_audit" => :get_data_redaction_settings,
43
+ "reporting_identity_audit" => :get_reporting_identity_settings,
44
+ "attribution_audit" => :get_attribution_settings,
45
+ "channel_group_audit" => :list_channel_groups,
46
+ "calculated_metric_audit" => :list_calculated_metrics,
47
+ "event_create_rule_audit" => :list_event_create_rules,
48
+ "event_edit_rule_audit" => :list_event_edit_rules,
49
+ "bigquery_link_audit" => :list_big_query_links,
50
+ "change_history" => :search_change_history_events,
51
+ "access_report" => :run_access_report
34
52
  }.freeze
35
53
 
36
54
  def initialize(client: nil, service_account: nil, access: :read, transport: :grpc, timeout: nil, logger: nil)
@@ -176,6 +194,13 @@ module AnalyticsOps
176
194
  translate_errors { response.respond_to?(:to_a) ? response.to_a : Array(response) }
177
195
  end
178
196
 
197
+ def list_first(method_name, request, limit)
198
+ response = invoke(method_name, request)
199
+ translate_errors do
200
+ response.respond_to?(:first) ? response.first(limit) : Array(response).first(limit)
201
+ end
202
+ end
203
+
179
204
  def get(method_name, request)
180
205
  invoke(method_name, request)
181
206
  end
@@ -197,7 +222,8 @@ module AnalyticsOps
197
222
  end
198
223
 
199
224
  def request_resource(request)
200
- request[:name] || request[:parent] || request.dig(:data_stream, :name) ||
225
+ request[:name] || request[:parent] || request[:property] || request[:entity] ||
226
+ request.dig(:data_stream, :name) ||
201
227
  request.dig(:data_retention_settings, :name)
202
228
  end
203
229
 
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AnalyticsOps
4
+ module Clients
5
+ # Experimental read-only Admin API governance operations.
6
+ module AdminGovernance
7
+ def governance_snapshot(property_id)
8
+ property = property_name(property_id)
9
+ streams = list_streams(property_id).select { |stream| stream.type == "web" }.sort_by(&:id)
10
+ Governance::Snapshot.new(
11
+ property_id:,
12
+ stream_settings: governance_stream_settings(streams),
13
+ reporting_identity: resource_hash(
14
+ get(:get_reporting_identity_settings, name: "#{property}/reportingIdentitySettings")
15
+ ),
16
+ attribution: resource_hash(get(:get_attribution_settings, name: "#{property}/attributionSettings")),
17
+ channel_groups: normalized_resource_list(:list_channel_groups, parent: property),
18
+ calculated_metrics: normalized_resource_list(:list_calculated_metrics, parent: property),
19
+ event_create_rules: streams.flat_map do |stream|
20
+ normalized_resource_list(:list_event_create_rules, parent: stream.name)
21
+ end,
22
+ event_edit_rules: streams.flat_map do |stream|
23
+ normalized_resource_list(:list_event_edit_rules, parent: stream.name)
24
+ end,
25
+ bigquery_links: normalized_resource_list(:list_big_query_links, parent: property)
26
+ )
27
+ end
28
+
29
+ def change_history(property_id, limit: 100, days: 30)
30
+ validate_bounded_integer!(limit, "history limit", 1, 200)
31
+ validate_bounded_integer!(days, "history days", 1, 365)
32
+ property = property_name(property_id)
33
+ account = property_access(property_id).parent
34
+ unless account.is_a?(String) && account.match?(%r{\Aaccounts/\d{1,50}\z})
35
+ raise RemoteError, "Google Admin API returned an invalid parent account"
36
+ end
37
+
38
+ now = Time.now.utc
39
+ events = list_first(
40
+ :search_change_history_events,
41
+ {
42
+ account:,
43
+ property:,
44
+ earliest_change_time: now - (days * 86_400),
45
+ latest_change_time: now,
46
+ page_size: limit
47
+ },
48
+ limit
49
+ ).map { |event| normalize_change_history_event(event) }
50
+ Governance::ChangeHistory.new(property_id:, events:)
51
+ end
52
+
53
+ def access_report(property_id, date_ranges:, limit: 1_000)
54
+ validate_bounded_integer!(limit, "access report limit", 1, 100_000)
55
+ unless date_ranges.is_a?(Array) && date_ranges.length.between?(1, 2)
56
+ raise InvalidRequestError, "Access report requires one or two date ranges"
57
+ end
58
+
59
+ response = get(
60
+ :run_access_report,
61
+ entity: property_name(property_id),
62
+ dimensions: [{ dimension_name: "accessDateHour" }],
63
+ metrics: [{ metric_name: "dataApiQuotaPropertyTokensConsumed" }],
64
+ date_ranges: date_ranges.map do |range|
65
+ { start_date: range.fetch("start_date"), end_date: range.fetch("end_date") }
66
+ end,
67
+ limit:,
68
+ return_entity_quota: true,
69
+ include_all_users: false,
70
+ expand_groups: false
71
+ )
72
+ normalize_access_report(property_id, response)
73
+ rescue KeyError
74
+ raise InvalidRequestError, "Access report date range is invalid"
75
+ end
76
+
77
+ private
78
+
79
+ def governance_stream_settings(streams)
80
+ streams.map do |stream|
81
+ Governance::StreamSettings.new(
82
+ stream_id: stream.id,
83
+ stream_name: stream.name,
84
+ enhanced_measurement: resource_hash(
85
+ get(:get_enhanced_measurement_settings, name: "#{stream.name}/enhancedMeasurementSettings")
86
+ ),
87
+ data_redaction: resource_hash(
88
+ get(:get_data_redaction_settings, name: "#{stream.name}/dataRedactionSettings")
89
+ )
90
+ )
91
+ end
92
+ end
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AnalyticsOps
4
+ module Clients
5
+ # Normalizes experimental governance, access, and history API responses.
6
+ module AdminGovernanceNormalization
7
+ private
8
+
9
+ def normalized_resource_list(method_name, parent:)
10
+ list(method_name, parent:, page_size: 200)
11
+ .map { |item| resource_hash(item) }
12
+ .sort_by { |item| item.fetch("name", "") }
13
+ end
14
+
15
+ def normalize_change_history_event(value)
16
+ event = resource_hash(value)
17
+ raw_changes = Array(event["changes"])
18
+ if raw_changes.length > 1_000
19
+ raise RemoteError, "Google Admin API returned too many changes in one history event"
20
+ end
21
+
22
+ filtered = event.fetch("changes_filtered", false)
23
+ unless [true, false].include?(filtered)
24
+ raise RemoteError, "Google Admin API returned an invalid change-history filter flag"
25
+ end
26
+
27
+ {
28
+ "id" => remote_string(event["id"], "change-history ID"),
29
+ "change_time" => remote_string(event["change_time"], "change-history time"),
30
+ "actor_type" => remote_string(event["actor_type"], "change-history actor type"),
31
+ "changes_filtered" => filtered,
32
+ "changes" => raw_changes.map { |change| normalize_history_change(change) }
33
+ }
34
+ end
35
+
36
+ def normalize_history_change(change)
37
+ raise RemoteError, "Google Admin API returned an invalid change-history change" unless change.is_a?(Hash)
38
+
39
+ {
40
+ "resource" => remote_string(change["resource"], "changed resource"),
41
+ "action" => remote_string(change["action"], "change action")
42
+ }
43
+ end
44
+
45
+ def normalize_access_report(property_id, response)
46
+ dimensions = access_headers(response, :dimension_headers, :dimension_name, "access dimension header")
47
+ metrics = access_headers(response, :metric_headers, :metric_name, "access metric header")
48
+ headers = dimensions + metrics
49
+ rows = array_field(response, :rows).map { |row| normalize_access_row(row, headers) }
50
+ row_count = field(response, :row_count)
51
+ unless row_count.is_a?(Integer) && row_count >= rows.length
52
+ raise RemoteError, "Google Admin API returned an invalid access row count"
53
+ end
54
+
55
+ Governance::AccessReport.new(
56
+ property_id:,
57
+ dimension_headers: dimensions,
58
+ metric_headers: metrics,
59
+ rows:,
60
+ row_count:,
61
+ quota: field(response, :quota) ? resource_hash(field(response, :quota)) : {}
62
+ )
63
+ end
64
+
65
+ def access_headers(response, collection, field_name, label)
66
+ array_field(response, collection).map do |header|
67
+ remote_string(field(header, field_name), label)
68
+ end
69
+ end
70
+
71
+ def normalize_access_row(row, headers)
72
+ values = array_field(row, :dimension_values).map do |item|
73
+ remote_string(field(item, :value), "access dimension value")
74
+ end
75
+ values += array_field(row, :metric_values).map do |item|
76
+ remote_string(field(item, :value), "access metric value")
77
+ end
78
+ raise RemoteError, "Google Admin API returned an invalid access row" unless values.length == headers.length
79
+
80
+ headers.zip(values).to_h
81
+ end
82
+
83
+ def validate_bounded_integer!(value, label, minimum, maximum)
84
+ return if value.is_a?(Integer) && value.between?(minimum, maximum)
85
+
86
+ raise InvalidRequestError, "#{label} must be between #{minimum} and #{maximum}"
87
+ end
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AnalyticsOps
4
+ module Clients
5
+ # Strictly normalizes generated Google resource values into gem-owned data.
6
+ module AdminResourceNormalization
7
+ private
8
+
9
+ def resource_hash(value)
10
+ raw = value.respond_to?(:to_h) ? value.to_h : value
11
+ normalized_remote_value(raw, depth: 0)
12
+ end
13
+
14
+ def normalized_remote_value(value, depth:)
15
+ raise RemoteError, "Google Admin API returned an excessively nested resource" if depth > 16
16
+
17
+ case value
18
+ when Hash then normalize_remote_hash(value, depth)
19
+ when Array then normalize_remote_array(value, depth)
20
+ when String then remote_string(value, "resource value")
21
+ when Symbol then normalize_enum(value)
22
+ when Integer, TrueClass, FalseClass, NilClass then value
23
+ when Float then normalize_remote_float(value)
24
+ else normalize_remote_object(value, depth)
25
+ end
26
+ end
27
+
28
+ def normalize_remote_hash(value, depth)
29
+ raise RemoteError, "Google Admin API returned an oversized resource" if value.length > 1_000
30
+
31
+ value.to_h do |key, child|
32
+ name = key.to_s
33
+ unless name.match?(/\A[a-zA-Z][a-zA-Z0-9_]{0,127}\z/)
34
+ raise RemoteError, "Google Admin API returned an invalid resource field"
35
+ end
36
+
37
+ [name, normalized_remote_value(child, depth: depth + 1)]
38
+ end
39
+ end
40
+
41
+ def normalize_remote_array(value, depth)
42
+ raise RemoteError, "Google Admin API returned an oversized resource array" if value.length > 10_000
43
+
44
+ value.map { |child| normalized_remote_value(child, depth: depth + 1) }
45
+ end
46
+
47
+ def normalize_remote_float(value)
48
+ raise RemoteError, "Google Admin API returned a non-finite resource value" unless value.finite?
49
+
50
+ value
51
+ end
52
+
53
+ def normalize_remote_object(value, depth)
54
+ return value.to_time.utc.iso8601(9) if value.respond_to?(:to_time)
55
+ return normalized_remote_value(value.to_h, depth: depth + 1) if value.respond_to?(:to_h)
56
+
57
+ raise RemoteError, "Google Admin API returned an unsupported resource value"
58
+ end
59
+ end
60
+ end
61
+ end