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
data/lib/analytics_ops/cli.rb
CHANGED
|
@@ -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
|
|
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[
|
|
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
|
|
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
|
-
|
|
185
|
+
render_workspace_overview(workspace)
|
|
158
186
|
when "report"
|
|
159
|
-
|
|
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
|
|
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
|
|
381
|
-
|
|
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
|
-
|
|
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
|
|
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
|