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
@@ -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, "#{profile_path}.key_events[#{index}]")
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