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
|
@@ -47,7 +47,13 @@ module AnalyticsOps
|
|
|
47
47
|
"manual_requirements" => {
|
|
48
48
|
"type" => "array", "uniqueItems" => true,
|
|
49
49
|
"items" => { "type" => "string", "pattern" => "^[A-Za-z][A-Za-z0-9_]{0,63}$" }
|
|
50
|
-
}
|
|
50
|
+
},
|
|
51
|
+
"labels" => { "$ref" => "#/$defs/labels" },
|
|
52
|
+
"reports" => { "$ref" => "#/$defs/reports" },
|
|
53
|
+
"health" => { "$ref" => "#/$defs/health" },
|
|
54
|
+
"governance" => { "$ref" => "#/$defs/governance" },
|
|
55
|
+
"bigquery" => { "$ref" => "#/$defs/bigquery" },
|
|
56
|
+
"funnels" => { "$ref" => "#/$defs/funnels" }
|
|
51
57
|
}
|
|
52
58
|
},
|
|
53
59
|
"stream" => {
|
|
@@ -143,6 +149,174 @@ module AnalyticsOps
|
|
|
143
149
|
"else" => { "properties" => { "restricted_metric_types" => { "maxItems" => 0 } } }
|
|
144
150
|
}
|
|
145
151
|
]
|
|
152
|
+
},
|
|
153
|
+
"labels" => {
|
|
154
|
+
"type" => "object",
|
|
155
|
+
"maxProperties" => 16,
|
|
156
|
+
"propertyNames" => { "pattern" => "^[A-Za-z][A-Za-z0-9_]{0,63}$" },
|
|
157
|
+
"additionalProperties" => {
|
|
158
|
+
"type" => "string", "minLength" => 1, "maxLength" => 64,
|
|
159
|
+
"pattern" => "^[^\\u0000-\\u001F\\u007F]+$"
|
|
160
|
+
}
|
|
161
|
+
},
|
|
162
|
+
"apiName" => {
|
|
163
|
+
"type" => "string",
|
|
164
|
+
"pattern" => "^[a-z][a-zA-Z0-9_]*(?::[a-zA-Z][a-zA-Z0-9_]*)?$"
|
|
165
|
+
},
|
|
166
|
+
"dateRange" => {
|
|
167
|
+
"type" => "object",
|
|
168
|
+
"additionalProperties" => false,
|
|
169
|
+
"required" => %w[start_date end_date],
|
|
170
|
+
"properties" => {
|
|
171
|
+
"start_date" => { "type" => "string" },
|
|
172
|
+
"end_date" => { "type" => "string" },
|
|
173
|
+
"name" => { "type" => "string", "pattern" => "^[a-z][a-z0-9_]{0,39}$" }
|
|
174
|
+
}
|
|
175
|
+
},
|
|
176
|
+
"filterExpression" => {
|
|
177
|
+
"oneOf" => [
|
|
178
|
+
{
|
|
179
|
+
"type" => "object", "additionalProperties" => false, "required" => ["and"],
|
|
180
|
+
"properties" => {
|
|
181
|
+
"and" => {
|
|
182
|
+
"type" => "array", "minItems" => 2, "maxItems" => 20,
|
|
183
|
+
"items" => { "$ref" => "#/$defs/filterExpression" }
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
},
|
|
187
|
+
{
|
|
188
|
+
"type" => "object", "additionalProperties" => false, "required" => ["or"],
|
|
189
|
+
"properties" => {
|
|
190
|
+
"or" => {
|
|
191
|
+
"type" => "array", "minItems" => 2, "maxItems" => 20,
|
|
192
|
+
"items" => { "$ref" => "#/$defs/filterExpression" }
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
},
|
|
196
|
+
{
|
|
197
|
+
"type" => "object", "additionalProperties" => false, "required" => ["not"],
|
|
198
|
+
"properties" => { "not" => { "$ref" => "#/$defs/filterExpression" } }
|
|
199
|
+
},
|
|
200
|
+
{
|
|
201
|
+
"type" => "object",
|
|
202
|
+
"required" => ["field"],
|
|
203
|
+
"properties" => {
|
|
204
|
+
"field" => { "$ref" => "#/$defs/apiName" },
|
|
205
|
+
"match_type" => {
|
|
206
|
+
"enum" => Reports::Definition::MATCH_TYPES
|
|
207
|
+
},
|
|
208
|
+
"value" => { "type" => %w[string number] },
|
|
209
|
+
"case_sensitive" => { "type" => "boolean" },
|
|
210
|
+
"in_list" => {
|
|
211
|
+
"type" => "array", "minItems" => 1, "maxItems" => 100,
|
|
212
|
+
"uniqueItems" => true, "items" => { "type" => "string" }
|
|
213
|
+
},
|
|
214
|
+
"operation" => { "enum" => Reports::Definition::NUMERIC_OPERATIONS },
|
|
215
|
+
"between" => {
|
|
216
|
+
"type" => "array", "minItems" => 2, "maxItems" => 2,
|
|
217
|
+
"items" => { "type" => "number" }
|
|
218
|
+
},
|
|
219
|
+
"null" => { "const" => true }
|
|
220
|
+
},
|
|
221
|
+
"additionalProperties" => false
|
|
222
|
+
}
|
|
223
|
+
]
|
|
224
|
+
},
|
|
225
|
+
"report" => {
|
|
226
|
+
"type" => "object",
|
|
227
|
+
"additionalProperties" => false,
|
|
228
|
+
"required" => ["metrics"],
|
|
229
|
+
"properties" => {
|
|
230
|
+
"kind" => { "enum" => Reports::Definition::KINDS },
|
|
231
|
+
"dimensions" => {
|
|
232
|
+
"type" => "array", "maxItems" => 9, "uniqueItems" => true,
|
|
233
|
+
"items" => { "$ref" => "#/$defs/apiName" }
|
|
234
|
+
},
|
|
235
|
+
"metrics" => {
|
|
236
|
+
"type" => "array", "minItems" => 1, "maxItems" => 10, "uniqueItems" => true,
|
|
237
|
+
"items" => { "$ref" => "#/$defs/apiName" }
|
|
238
|
+
},
|
|
239
|
+
"date_ranges" => {
|
|
240
|
+
"type" => "array", "maxItems" => 4, "items" => { "$ref" => "#/$defs/dateRange" }
|
|
241
|
+
},
|
|
242
|
+
"dimension_filter" => { "$ref" => "#/$defs/filterExpression" },
|
|
243
|
+
"metric_filter" => { "$ref" => "#/$defs/filterExpression" },
|
|
244
|
+
"order_bys" => { "type" => "array", "maxItems" => 10, "items" => { "type" => "object" } },
|
|
245
|
+
"limit" => { "type" => "integer", "minimum" => 1, "maximum" => 250_000 }
|
|
246
|
+
}
|
|
247
|
+
},
|
|
248
|
+
"reports" => {
|
|
249
|
+
"type" => "object", "maxProperties" => 50,
|
|
250
|
+
"propertyNames" => { "pattern" => "^[A-Za-z][A-Za-z0-9_]{0,63}$" },
|
|
251
|
+
"additionalProperties" => { "$ref" => "#/$defs/report" }
|
|
252
|
+
},
|
|
253
|
+
"health" => {
|
|
254
|
+
"type" => "object", "additionalProperties" => false,
|
|
255
|
+
"properties" => {
|
|
256
|
+
"expected_events" => {
|
|
257
|
+
"type" => "array", "uniqueItems" => true,
|
|
258
|
+
"items" => { "type" => "string", "pattern" => "^[A-Za-z][A-Za-z0-9_]{0,39}$" }
|
|
259
|
+
},
|
|
260
|
+
"minimum_sessions" => { "type" => "integer", "minimum" => 0, "maximum" => 1_000_000_000 },
|
|
261
|
+
"traffic_drop_percent" => { "type" => "number", "minimum" => 0, "maximum" => 100 },
|
|
262
|
+
"key_event_drop_percent" => { "type" => "number", "minimum" => 0, "maximum" => 100 },
|
|
263
|
+
"unassigned_percent" => { "type" => "number", "minimum" => 0, "maximum" => 100 },
|
|
264
|
+
"not_set_percent" => { "type" => "number", "minimum" => 0, "maximum" => 100 }
|
|
265
|
+
}
|
|
266
|
+
},
|
|
267
|
+
"governance" => {
|
|
268
|
+
"type" => "object", "additionalProperties" => false, "required" => ["experimental"],
|
|
269
|
+
"properties" => {
|
|
270
|
+
"experimental" => { "const" => true },
|
|
271
|
+
"enhanced_measurement_required" => { "type" => "boolean" },
|
|
272
|
+
"email_redaction_required" => { "type" => "boolean" },
|
|
273
|
+
"query_parameter_redaction_required" => { "type" => "boolean" },
|
|
274
|
+
"reporting_identity" => { "enum" => %w[blended observed device_based] },
|
|
275
|
+
"bigquery_link_required" => { "type" => "boolean" },
|
|
276
|
+
"expected_channel_groups" => { "$ref" => "#/$defs/stringList" },
|
|
277
|
+
"expected_calculated_metrics" => { "$ref" => "#/$defs/stringList" },
|
|
278
|
+
"expected_event_create_rules" => { "$ref" => "#/$defs/stringList" },
|
|
279
|
+
"expected_event_edit_rules" => { "$ref" => "#/$defs/stringList" }
|
|
280
|
+
}
|
|
281
|
+
},
|
|
282
|
+
"stringList" => {
|
|
283
|
+
"type" => "array", "uniqueItems" => true,
|
|
284
|
+
"items" => { "type" => "string", "minLength" => 1, "maxLength" => 128 }
|
|
285
|
+
},
|
|
286
|
+
"bigquery" => {
|
|
287
|
+
"type" => "object", "additionalProperties" => false,
|
|
288
|
+
"required" => %w[project_id dataset maximum_bytes_billed experimental],
|
|
289
|
+
"properties" => {
|
|
290
|
+
"project_id" => { "type" => "string", "pattern" => "^[a-z][a-z0-9-]{4,28}[a-z0-9]$" },
|
|
291
|
+
"dataset" => { "type" => "string", "pattern" => "^[A-Za-z_][A-Za-z0-9_]{0,1023}$" },
|
|
292
|
+
"location" => { "type" => "string", "pattern" => "^[A-Za-z][A-Za-z0-9-]{0,31}$" },
|
|
293
|
+
"maximum_bytes_billed" => {
|
|
294
|
+
"type" => "integer", "minimum" => 1, "maximum" => 10_000_000_000_000_000
|
|
295
|
+
},
|
|
296
|
+
"experimental" => { "const" => true }
|
|
297
|
+
}
|
|
298
|
+
},
|
|
299
|
+
"funnelStep" => {
|
|
300
|
+
"type" => "object", "additionalProperties" => false, "required" => %w[name event_name],
|
|
301
|
+
"properties" => {
|
|
302
|
+
"name" => { "type" => "string", "minLength" => 1, "maxLength" => 64 },
|
|
303
|
+
"event_name" => { "type" => "string", "pattern" => "^[A-Za-z][A-Za-z0-9_]{0,39}$" }
|
|
304
|
+
}
|
|
305
|
+
},
|
|
306
|
+
"funnel" => {
|
|
307
|
+
"type" => "object", "additionalProperties" => false, "required" => %w[steps experimental],
|
|
308
|
+
"properties" => {
|
|
309
|
+
"steps" => {
|
|
310
|
+
"type" => "array", "minItems" => 2, "maxItems" => 10,
|
|
311
|
+
"items" => { "$ref" => "#/$defs/funnelStep" }
|
|
312
|
+
},
|
|
313
|
+
"experimental" => { "const" => true }
|
|
314
|
+
}
|
|
315
|
+
},
|
|
316
|
+
"funnels" => {
|
|
317
|
+
"type" => "object", "maxProperties" => 20,
|
|
318
|
+
"propertyNames" => { "pattern" => "^[A-Za-z][A-Za-z0-9_]{0,63}$" },
|
|
319
|
+
"additionalProperties" => { "$ref" => "#/$defs/funnel" }
|
|
146
320
|
}
|
|
147
321
|
}
|
|
148
322
|
)
|
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
# Strict configuration validation.
|
|
4
4
|
|
|
5
5
|
require "uri"
|
|
6
|
+
require_relative "validator_experimental"
|
|
7
|
+
require_relative "validator_reporting"
|
|
6
8
|
|
|
7
9
|
module AnalyticsOps
|
|
8
10
|
module Configuration
|
|
@@ -10,7 +12,22 @@ module AnalyticsOps
|
|
|
10
12
|
class Validator
|
|
11
13
|
TOP_LEVEL_KEYS = %w[version profiles].freeze
|
|
12
14
|
PROFILE_KEYS = %w[property_id streams retention google_signals key_events custom_dimensions custom_metrics
|
|
13
|
-
manual_requirements].freeze
|
|
15
|
+
manual_requirements labels reports health funnels bigquery governance].freeze
|
|
16
|
+
REPORT_KEYS = %w[
|
|
17
|
+
kind dimensions metrics date_ranges dimension_filter metric_filter order_bys limit
|
|
18
|
+
].freeze
|
|
19
|
+
HEALTH_KEYS = %w[
|
|
20
|
+
expected_events minimum_sessions traffic_drop_percent key_event_drop_percent
|
|
21
|
+
unassigned_percent not_set_percent
|
|
22
|
+
].freeze
|
|
23
|
+
GOVERNANCE_KEYS = %w[
|
|
24
|
+
enhanced_measurement_required email_redaction_required query_parameter_redaction_required
|
|
25
|
+
reporting_identity bigquery_link_required expected_channel_groups expected_calculated_metrics
|
|
26
|
+
expected_event_create_rules expected_event_edit_rules experimental
|
|
27
|
+
].freeze
|
|
28
|
+
BIGQUERY_KEYS = %w[project_id dataset location maximum_bytes_billed experimental].freeze
|
|
29
|
+
FUNNEL_KEYS = %w[steps experimental].freeze
|
|
30
|
+
FUNNEL_STEP_KEYS = %w[name event_name].freeze
|
|
14
31
|
STREAM_KEYS = %w[stream_id default_uri enhanced_measurement].freeze
|
|
15
32
|
ENHANCED_MEASUREMENT_KEYS = %w[enabled experimental].freeze
|
|
16
33
|
RETENTION_KEYS = %w[event_data user_data reset_on_new_activity].freeze
|
|
@@ -77,7 +94,13 @@ module AnalyticsOps
|
|
|
77
94
|
custom_dimensions: validate_dimensions(profile.fetch("custom_dimensions", []), path),
|
|
78
95
|
custom_metrics: validate_metrics(profile.fetch("custom_metrics", []), path),
|
|
79
96
|
manual_requirements: validate_manual_requirements(profile.fetch("manual_requirements", []), path),
|
|
80
|
-
google_signals: validate_google_signals(profile["google_signals"], path)
|
|
97
|
+
google_signals: validate_google_signals(profile["google_signals"], path),
|
|
98
|
+
labels: validate_labels(profile.fetch("labels", {}), path),
|
|
99
|
+
reports: validate_reports(profile.fetch("reports", {}), path),
|
|
100
|
+
health: validate_health(profile["health"], path),
|
|
101
|
+
governance: validate_governance(profile["governance"], path),
|
|
102
|
+
bigquery: validate_bigquery(profile["bigquery"], path),
|
|
103
|
+
funnels: validate_funnels(profile.fetch("funnels", {}), path)
|
|
81
104
|
)
|
|
82
105
|
end
|
|
83
106
|
|
|
@@ -96,7 +119,7 @@ module AnalyticsOps
|
|
|
96
119
|
"enhanced_measurement" => validate_enhanced_measurement(stream["enhanced_measurement"], stream_path)
|
|
97
120
|
}
|
|
98
121
|
end
|
|
99
|
-
streams.sort_by { |stream| stream.fetch("name") }
|
|
122
|
+
ensure_unique!(streams, ["stream_id"], path).sort_by { |stream| stream.fetch("name") }
|
|
100
123
|
end
|
|
101
124
|
|
|
102
125
|
def validate_enhanced_measurement(raw, stream_path)
|
|
@@ -140,11 +163,13 @@ module AnalyticsOps
|
|
|
140
163
|
|
|
141
164
|
def validate_key_events(raw, profile_path)
|
|
142
165
|
path = "#{profile_path}.key_events"
|
|
166
|
+
validate_event_names(raw, path)
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def validate_event_names(raw, path)
|
|
143
170
|
values = array!(raw, path).map.with_index do |event_name, index|
|
|
144
|
-
string = string!(event_name, "#{
|
|
145
|
-
unless EVENT_NAME.match?(string)
|
|
146
|
-
raise ConfigurationError, "#{profile_path}.key_events[#{index}] is not a valid GA4 event name"
|
|
147
|
-
end
|
|
171
|
+
string = string!(event_name, "#{path}[#{index}]")
|
|
172
|
+
raise ConfigurationError, "#{path}[#{index}] is not a valid GA4 event name" unless EVENT_NAME.match?(string)
|
|
148
173
|
|
|
149
174
|
string
|
|
150
175
|
end
|
|
@@ -354,6 +379,19 @@ module AnalyticsOps
|
|
|
354
379
|
raise ConfigurationError, "#{path} must be true or false"
|
|
355
380
|
end
|
|
356
381
|
|
|
382
|
+
def integer!(value, path, minimum:, maximum:)
|
|
383
|
+
return value if value.is_a?(Integer) && value.between?(minimum, maximum)
|
|
384
|
+
|
|
385
|
+
raise ConfigurationError, "#{path} must be an integer between #{minimum} and #{maximum}"
|
|
386
|
+
end
|
|
387
|
+
|
|
388
|
+
def percentage!(value, path)
|
|
389
|
+
valid = (value.is_a?(Integer) || value.is_a?(Float)) && value.finite? && value.between?(0, 100)
|
|
390
|
+
return value.to_f if valid
|
|
391
|
+
|
|
392
|
+
raise ConfigurationError, "#{path} must be a finite number between 0 and 100"
|
|
393
|
+
end
|
|
394
|
+
|
|
357
395
|
def id!(value, path)
|
|
358
396
|
string = string!(value, path)
|
|
359
397
|
raise ConfigurationError, "#{path} must be a numeric identifier encoded as a string" unless ID.match?(string)
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AnalyticsOps
|
|
4
|
+
module Configuration
|
|
5
|
+
# Reopens the strict validator to add opt-in feature validation.
|
|
6
|
+
class Validator
|
|
7
|
+
# Validation for opt-in BigQuery, funnel, and governance features.
|
|
8
|
+
module ExperimentalValidation
|
|
9
|
+
private
|
|
10
|
+
|
|
11
|
+
def validate_funnels(raw, profile_path)
|
|
12
|
+
path = "#{profile_path}.funnels"
|
|
13
|
+
funnels = hash!(raw, path)
|
|
14
|
+
raise ConfigurationError, "#{path} can contain at most 20 funnels" if funnels.length > 20
|
|
15
|
+
|
|
16
|
+
funnels.sort.to_h do |name, value|
|
|
17
|
+
validate_name!(name, "#{path} key")
|
|
18
|
+
item_path = "#{path}.#{name}"
|
|
19
|
+
funnel = hash!(value, item_path)
|
|
20
|
+
exact_keys!(funnel, FUNNEL_KEYS, item_path)
|
|
21
|
+
unless boolean!(required(funnel, "experimental", item_path), "#{item_path}.experimental")
|
|
22
|
+
raise ConfigurationError, "#{item_path}.experimental must be true"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
[name, validated_funnel(name, funnel, item_path)]
|
|
26
|
+
rescue InvalidRequestError => error
|
|
27
|
+
raise ConfigurationError, "#{item_path}: #{error.message}"
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def validated_funnel(name, funnel, item_path)
|
|
32
|
+
raw_steps = array!(required(funnel, "steps", item_path), "#{item_path}.steps")
|
|
33
|
+
steps = raw_steps.map.with_index do |step, index|
|
|
34
|
+
step_path = "#{item_path}.steps[#{index}]"
|
|
35
|
+
value = hash!(step, step_path)
|
|
36
|
+
exact_keys!(value, FUNNEL_STEP_KEYS, step_path)
|
|
37
|
+
{
|
|
38
|
+
"name" => string!(required(value, "name", step_path), "#{step_path}.name"),
|
|
39
|
+
"event_name" => string!(required(value, "event_name", step_path), "#{step_path}.event_name")
|
|
40
|
+
}
|
|
41
|
+
end
|
|
42
|
+
Funnels::Definition.new(name:, steps:)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def validate_bigquery(raw, profile_path)
|
|
46
|
+
return nil if raw.nil?
|
|
47
|
+
|
|
48
|
+
path = "#{profile_path}.bigquery"
|
|
49
|
+
settings = hash!(raw, path)
|
|
50
|
+
exact_keys!(settings, BIGQUERY_KEYS, path)
|
|
51
|
+
unless boolean!(required(settings, "experimental", path), "#{path}.experimental")
|
|
52
|
+
raise ConfigurationError, "#{path}.experimental must be true"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
project_id = string!(required(settings, "project_id", path), "#{path}.project_id")
|
|
56
|
+
dataset = string!(required(settings, "dataset", path), "#{path}.dataset")
|
|
57
|
+
location = string!(settings.fetch("location", "US"), "#{path}.location")
|
|
58
|
+
validate_bigquery_names!(project_id, dataset, location, path)
|
|
59
|
+
{
|
|
60
|
+
"project_id" => project_id,
|
|
61
|
+
"dataset" => dataset,
|
|
62
|
+
"location" => location,
|
|
63
|
+
"maximum_bytes_billed" => integer!(
|
|
64
|
+
required(settings, "maximum_bytes_billed", path),
|
|
65
|
+
"#{path}.maximum_bytes_billed",
|
|
66
|
+
minimum: 1,
|
|
67
|
+
maximum: 10_000_000_000_000_000
|
|
68
|
+
),
|
|
69
|
+
"experimental" => true
|
|
70
|
+
}
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def validate_bigquery_names!(project_id, dataset, location, path)
|
|
74
|
+
unless project_id.match?(/\A[a-z][a-z0-9-]{4,28}[a-z0-9]\z/)
|
|
75
|
+
raise ConfigurationError, "#{path}.project_id is invalid"
|
|
76
|
+
end
|
|
77
|
+
unless dataset.match?(/\A[A-Za-z_][A-Za-z0-9_]{0,1023}\z/)
|
|
78
|
+
raise ConfigurationError, "#{path}.dataset is invalid"
|
|
79
|
+
end
|
|
80
|
+
return if location.match?(/\A[A-Za-z][A-Za-z0-9-]{0,31}\z/)
|
|
81
|
+
|
|
82
|
+
raise ConfigurationError, "#{path}.location is invalid"
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def validate_governance(raw, profile_path)
|
|
86
|
+
return nil if raw.nil?
|
|
87
|
+
|
|
88
|
+
path = "#{profile_path}.governance"
|
|
89
|
+
settings = hash!(raw, path)
|
|
90
|
+
exact_keys!(settings, GOVERNANCE_KEYS, path)
|
|
91
|
+
unless boolean!(required(settings, "experimental", path), "#{path}.experimental")
|
|
92
|
+
raise ConfigurationError, "#{path}.experimental must be true"
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
governance_settings(settings, path)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def governance_settings(settings, path)
|
|
99
|
+
result = { "experimental" => true }
|
|
100
|
+
%w[
|
|
101
|
+
enhanced_measurement_required email_redaction_required
|
|
102
|
+
query_parameter_redaction_required bigquery_link_required
|
|
103
|
+
].each { |name| result[name] = boolean!(settings.fetch(name, false), "#{path}.#{name}") }
|
|
104
|
+
if settings.key?("reporting_identity")
|
|
105
|
+
result["reporting_identity"] = enum!(
|
|
106
|
+
settings.fetch("reporting_identity"),
|
|
107
|
+
%w[blended observed device_based],
|
|
108
|
+
"#{path}.reporting_identity"
|
|
109
|
+
)
|
|
110
|
+
end
|
|
111
|
+
%w[
|
|
112
|
+
expected_channel_groups expected_calculated_metrics
|
|
113
|
+
expected_event_create_rules expected_event_edit_rules
|
|
114
|
+
].each do |name|
|
|
115
|
+
result[name] = printable_string_array(settings.fetch(name, []), "#{path}.#{name}")
|
|
116
|
+
end
|
|
117
|
+
result
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def printable_string_array(raw, path)
|
|
121
|
+
values = array!(raw, path).map.with_index do |value, index|
|
|
122
|
+
string = string!(value, "#{path}[#{index}]")
|
|
123
|
+
unless !string.empty? && string.length <= 128 && !string.match?(/[\u0000-\u001f\u007f]/)
|
|
124
|
+
raise ConfigurationError, "#{path}[#{index}] must be a printable string of at most 128 characters"
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
string
|
|
128
|
+
end
|
|
129
|
+
ensure_unique_strings!(values, path)
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
include ExperimentalValidation
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AnalyticsOps
|
|
4
|
+
module Configuration
|
|
5
|
+
# Reopens the strict validator to add reporting configuration validation.
|
|
6
|
+
class Validator
|
|
7
|
+
# Validation for health settings, labels, and user-defined reports.
|
|
8
|
+
module ReportingValidation
|
|
9
|
+
private
|
|
10
|
+
|
|
11
|
+
def validate_health(raw, profile_path)
|
|
12
|
+
return nil if raw.nil?
|
|
13
|
+
|
|
14
|
+
path = "#{profile_path}.health"
|
|
15
|
+
health = hash!(raw, path)
|
|
16
|
+
exact_keys!(health, HEALTH_KEYS, path)
|
|
17
|
+
defaults = Health::DEFAULTS
|
|
18
|
+
{
|
|
19
|
+
"expected_events" => validate_event_names(
|
|
20
|
+
health.fetch("expected_events", defaults.fetch("expected_events")),
|
|
21
|
+
"#{path}.expected_events"
|
|
22
|
+
),
|
|
23
|
+
"minimum_sessions" => integer!(
|
|
24
|
+
health.fetch("minimum_sessions", defaults.fetch("minimum_sessions")),
|
|
25
|
+
"#{path}.minimum_sessions", minimum: 0, maximum: 1_000_000_000
|
|
26
|
+
),
|
|
27
|
+
"traffic_drop_percent" => percentage_setting(health, defaults, "traffic_drop_percent", path),
|
|
28
|
+
"key_event_drop_percent" => percentage_setting(health, defaults, "key_event_drop_percent", path),
|
|
29
|
+
"unassigned_percent" => percentage_setting(health, defaults, "unassigned_percent", path),
|
|
30
|
+
"not_set_percent" => percentage_setting(health, defaults, "not_set_percent", path)
|
|
31
|
+
}
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def percentage_setting(settings, defaults, name, path)
|
|
35
|
+
percentage!(settings.fetch(name, defaults.fetch(name)), "#{path}.#{name}")
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def validate_labels(raw, profile_path)
|
|
39
|
+
path = "#{profile_path}.labels"
|
|
40
|
+
labels = hash!(raw, path)
|
|
41
|
+
raise ConfigurationError, "#{path} can contain at most 16 labels" if labels.length > 16
|
|
42
|
+
|
|
43
|
+
labels.sort.to_h do |name, value|
|
|
44
|
+
validate_name!(name, "#{path} key")
|
|
45
|
+
label = string!(value, "#{path}.#{name}")
|
|
46
|
+
unless !label.empty? && label.length <= 64 && !label.match?(/[\u0000-\u001f\u007f]/)
|
|
47
|
+
raise ConfigurationError, "#{path}.#{name} must be a printable string of at most 64 characters"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
[name, label]
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def validate_reports(raw, profile_path)
|
|
55
|
+
path = "#{profile_path}.reports"
|
|
56
|
+
reports = hash!(raw, path)
|
|
57
|
+
raise ConfigurationError, "#{path} can contain at most 50 reports" if reports.length > 50
|
|
58
|
+
|
|
59
|
+
reports.sort.to_h do |name, value|
|
|
60
|
+
validate_name!(name, "#{path} key")
|
|
61
|
+
item_path = "#{path}.#{name}"
|
|
62
|
+
report = hash!(value, item_path)
|
|
63
|
+
exact_keys!(report, REPORT_KEYS, item_path)
|
|
64
|
+
[name, configured_report(name, report, item_path)]
|
|
65
|
+
rescue InvalidRequestError => error
|
|
66
|
+
raise ConfigurationError, "#{item_path}: #{error.message}"
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def configured_report(name, report, item_path)
|
|
71
|
+
kind = enum!(report.fetch("kind", "standard"), Reports::Definition::KINDS, "#{item_path}.kind")
|
|
72
|
+
date_ranges = report.fetch(
|
|
73
|
+
"date_ranges",
|
|
74
|
+
kind == "standard" ? Reports::Catalog::STANDARD_DATE_RANGE : []
|
|
75
|
+
)
|
|
76
|
+
Reports::Definition.new(
|
|
77
|
+
name:,
|
|
78
|
+
kind:,
|
|
79
|
+
dimensions: report.fetch("dimensions", []),
|
|
80
|
+
metrics: required(report, "metrics", item_path),
|
|
81
|
+
date_ranges:,
|
|
82
|
+
dimension_filter: report["dimension_filter"],
|
|
83
|
+
metric_filter: report["metric_filter"],
|
|
84
|
+
order_bys: report.fetch("order_bys", []),
|
|
85
|
+
limit: report.fetch("limit", 100)
|
|
86
|
+
)
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
include ReportingValidation
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|