analytics_ops 0.1.0 → 0.2.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 (56) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +76 -0
  3. data/CONTRIBUTING.md +2 -1
  4. data/README.md +232 -41
  5. data/SECURITY.md +8 -8
  6. data/docs/api-support-matrix.md +9 -8
  7. data/docs/architecture.md +22 -4
  8. data/docs/authentication.md +112 -51
  9. data/docs/commands.md +71 -17
  10. data/docs/configuration-schema-v1.json +53 -7
  11. data/docs/configuration.md +33 -9
  12. data/docs/google-client-compatibility.md +10 -6
  13. data/docs/live-smoke-test.md +159 -0
  14. data/docs/plan-format.md +4 -0
  15. data/docs/plan-schema-v1.json +113 -22
  16. data/docs/rails.md +12 -3
  17. data/docs/reports.md +44 -6
  18. data/docs/safety.md +18 -6
  19. data/docs/troubleshooting.md +42 -13
  20. data/lib/analytics_ops/applier.rb +2 -1
  21. data/lib/analytics_ops/cli/options.rb +157 -0
  22. data/lib/analytics_ops/cli/presenter.rb +221 -0
  23. data/lib/analytics_ops/cli.rb +172 -201
  24. data/lib/analytics_ops/clients/admin.rb +130 -129
  25. data/lib/analytics_ops/clients/admin_normalization.rb +95 -0
  26. data/lib/analytics_ops/clients/data.rb +120 -63
  27. data/lib/analytics_ops/clients/error_translation.rb +45 -0
  28. data/lib/analytics_ops/configuration/document.rb +1 -1
  29. data/lib/analytics_ops/configuration/loader.rb +38 -5
  30. data/lib/analytics_ops/configuration/schema.rb +51 -8
  31. data/lib/analytics_ops/configuration/validator.rb +43 -8
  32. data/lib/analytics_ops/configuration/writer.rb +105 -0
  33. data/lib/analytics_ops/configuration.rb +1 -0
  34. data/lib/analytics_ops/connection.rb +93 -0
  35. data/lib/analytics_ops/desired_state.rb +2 -2
  36. data/lib/analytics_ops/plan.rb +94 -33
  37. data/lib/analytics_ops/planner.rb +10 -2
  38. data/lib/analytics_ops/rails/railtie.rb +13 -18
  39. data/lib/analytics_ops/redaction.rb +14 -9
  40. data/lib/analytics_ops/reports/catalog.rb +22 -5
  41. data/lib/analytics_ops/reports/definition.rb +74 -37
  42. data/lib/analytics_ops/reports/overview.rb +55 -0
  43. data/lib/analytics_ops/reports/overview_result.rb +54 -0
  44. data/lib/analytics_ops/reports/result.rb +38 -9
  45. data/lib/analytics_ops/reports.rb +2 -0
  46. data/lib/analytics_ops/resources.rb +2 -1
  47. data/lib/analytics_ops/service_account.rb +171 -0
  48. data/lib/analytics_ops/setup.rb +171 -0
  49. data/lib/analytics_ops/snapshot.rb +20 -5
  50. data/lib/analytics_ops/version.rb +1 -1
  51. data/lib/analytics_ops/workspace.rb +41 -12
  52. data/lib/analytics_ops.rb +4 -0
  53. data/lib/generators/analytics_ops/install_generator.rb +2 -0
  54. data/lib/generators/analytics_ops/templates/analytics_ops.yml +2 -15
  55. data/sig/analytics_ops.rbs +117 -10
  56. metadata +26 -1
@@ -30,14 +30,15 @@ module AnalyticsOps
30
30
  potentially_thresholded_requests_per_hour
31
31
  ].freeze
32
32
 
33
- def initialize(client: nil, credentials: nil, transport: :grpc, timeout: nil, logger: nil)
34
- transport = transport.to_sym
35
- raise ConfigurationError, "transport must be grpc or rest" unless %i[grpc rest].include?(transport)
33
+ def initialize(client: nil, service_account: nil, transport: :grpc, timeout: nil, logger: nil)
34
+ unless service_account.nil? || service_account.is_a?(ServiceAccount)
35
+ raise ConfigurationError, "service_account must be an AnalyticsOps::ServiceAccount"
36
+ end
36
37
 
37
38
  @client = client
38
- @credentials = credentials
39
- @transport = transport
40
- @timeout = timeout
39
+ @service_account = service_account
40
+ @transport = validate_transport(transport)
41
+ @timeout = validate_timeout(timeout)
41
42
  @logger = logger
42
43
  end
43
44
 
@@ -55,20 +56,37 @@ module AnalyticsOps
55
56
  normalize_result(definition, response)
56
57
  end
57
58
 
59
+ def batch(property_id, definitions)
60
+ validate_batch_definitions!(definitions)
61
+
62
+ property = property_name(property_id)
63
+ requests = definitions.map do |definition|
64
+ standard_request(property_id, definition).reject { |key| key == :property }
65
+ end
66
+ response = invoke(:batch_run_reports, property:, requests:)
67
+ reports = array_field(response, :reports)
68
+ unless reports.length == definitions.length
69
+ raise RemoteError, "Google Data API returned a batch size that does not match the request"
70
+ end
71
+
72
+ definitions.zip(reports).map { |definition, report| normalize_result(definition, report) }.freeze
73
+ end
74
+
58
75
  def available?
59
76
  generated_client = translate_errors { client }
60
- generated_client.respond_to?(:run_report) && generated_client.respond_to?(:run_realtime_report)
77
+ generated_client.respond_to?(:run_report) && generated_client.respond_to?(:run_realtime_report) &&
78
+ generated_client.respond_to?(:batch_run_reports)
61
79
  end
62
80
 
63
81
  def compatibility
64
82
  specification = Gem::Specification.find_by_name("google-analytics-data")
65
- {
83
+ Canonical.immutable(
66
84
  "package" => specification.name,
67
85
  "version" => specification.version.to_s,
68
86
  "requirement" => PACKAGE_REQUIREMENT.to_s,
69
87
  "supported" => PACKAGE_REQUIREMENT.satisfied_by?(specification.version),
70
88
  "transport" => @transport.to_s
71
- }.freeze
89
+ )
72
90
  rescue Gem::LoadError => error
73
91
  raise UnsupportedCapabilityError, Redaction.message(error.message)
74
92
  end
@@ -77,16 +95,20 @@ module AnalyticsOps
77
95
 
78
96
  def client
79
97
  @client ||= begin
98
+ unless @service_account
99
+ raise AuthenticationError, "Analytics Ops requires configured service-account credentials"
100
+ end
101
+
80
102
  require "google/analytics/data"
81
103
  Google::Analytics::Data.analytics_data(transport: @transport) do |config|
82
- config.credentials = @credentials if @credentials
104
+ config.credentials = @service_account.__send__(:credentials, access: :read)
83
105
  config.timeout = @timeout if @timeout
84
106
  end
85
107
  end
86
108
  end
87
109
 
88
110
  def standard_request(property_id, definition)
89
- common_request(property_id, definition).merge(
111
+ request = common_request(property_id, definition).merge(
90
112
  offset: definition.offset,
91
113
  date_ranges: definition.date_ranges.map do |range|
92
114
  {
@@ -96,6 +118,8 @@ module AnalyticsOps
96
118
  }.compact
97
119
  end
98
120
  )
121
+ request[:keep_empty_rows] = true if definition.dimensions.empty?
122
+ request
99
123
  end
100
124
 
101
125
  def realtime_request(property_id, definition)
@@ -175,34 +199,31 @@ module AnalyticsOps
175
199
  translate_errors { generated_client.public_send(method_name, request) }
176
200
  end
177
201
 
178
- def translate_errors
179
- yield
180
- rescue AnalyticsOps::Error
181
- raise
182
- rescue StandardError => error
183
- translated = case error.class.name
184
- when /Unauthenticated|Google::Auth|Signet::Authorization/
185
- AuthenticationError
186
- when /PermissionDenied|Forbidden/
187
- AuthorizationError
188
- when /ResourceExhausted|TooManyRequests/
189
- QuotaError
190
- when /DeadlineExceeded|Timeout|ETIMEDOUT/
191
- TimeoutError
192
- when /InvalidArgument|FailedPrecondition|NotFound/
193
- InvalidRequestError
194
- when /Google::Cloud::|GRPC::|Faraday::|HTTP/
195
- RemoteError
196
- end
197
- raise unless translated
198
-
199
- raise translated, Redaction.message(error.message)
202
+ def translate_errors(&)
203
+ ErrorTranslation.call(&)
204
+ end
205
+
206
+ def validate_batch_definitions!(definitions)
207
+ valid = definitions.is_a?(Array) &&
208
+ definitions.length.between?(1, Reports::OverviewResult::MAX_REPORTS) &&
209
+ definitions.all? { |definition| definition.is_a?(Reports::Definition) && !definition.realtime? }
210
+ raise InvalidRequestError, "batch must contain 1 to 5 standard report definitions" unless valid
211
+
212
+ return if definitions.map(&:name).uniq.length == definitions.length
213
+
214
+ raise InvalidRequestError, "batch report definitions must have unique names"
200
215
  end
201
216
 
202
217
  def normalize_result(definition, response)
203
- dimension_headers = array_field(response, :dimension_headers).map { |header| field(header, :name).to_s }
204
- metric_headers = array_field(response, :metric_headers).map { |header| field(header, :name).to_s }
205
- unless dimension_headers == definition.dimensions && metric_headers == definition.metrics
218
+ dimension_headers = array_field(response, :dimension_headers).map do |header|
219
+ remote_string(field(header, :name), "dimension header")
220
+ end
221
+ metric_headers = array_field(response, :metric_headers).map do |header|
222
+ remote_string(field(header, :name), "metric header")
223
+ end
224
+ expected_dimensions = definition.dimensions.dup
225
+ expected_dimensions << "dateRange" if definition.date_ranges.length > 1
226
+ unless dimension_headers == expected_dimensions && metric_headers == definition.metrics
206
227
  raise RemoteError, "Google Data API returned headers that do not match the requested report"
207
228
  end
208
229
 
@@ -228,28 +249,31 @@ module AnalyticsOps
228
249
  end
229
250
 
230
251
  def values(row, name)
231
- array_field(row, name).map { |value| field(value, :value).to_s }
252
+ array_field(row, name).map { |value| remote_string(field(value, :value), "row value") }
232
253
  end
233
254
 
234
255
  def metadata(response)
235
256
  response_metadata = field(response, :metadata)
236
- result = {
237
- "data_loss_from_other_row" => boolean?(field(response_metadata, :data_loss_from_other_row)),
238
- "subject_to_thresholding" => boolean?(field(response_metadata, :subject_to_thresholding)),
239
- "currency_code" => optional_string(field(response_metadata, :currency_code)),
240
- "time_zone" => optional_string(field(response_metadata, :time_zone)),
241
- "empty_reason" => optional_string(field(response_metadata, :empty_reason)),
242
- "sampling" => sampling(response_metadata),
243
- "property_quota" => quota(field(response, :property_quota))
244
- }
257
+ result = {}
258
+ if response_metadata
259
+ result = {
260
+ "data_loss_from_other_row" => metadata_boolean(field(response_metadata, :data_loss_from_other_row)),
261
+ "subject_to_thresholding" => metadata_boolean(field(response_metadata, :subject_to_thresholding)),
262
+ "currency_code" => optional_string(field(response_metadata, :currency_code), "currency code"),
263
+ "time_zone" => optional_string(field(response_metadata, :time_zone), "time zone"),
264
+ "empty_reason" => optional_string(field(response_metadata, :empty_reason), "empty reason"),
265
+ "sampling" => sampling(response_metadata)
266
+ }
267
+ end
268
+ result["property_quota"] = quota(field(response, :property_quota))
245
269
  result.reject { |_key, value| value.nil? || value == [] }
246
270
  end
247
271
 
248
272
  def sampling(response_metadata)
249
273
  array_field(response_metadata, :sampling_metadatas).map do |sample|
250
274
  {
251
- "samples_read_count" => integer(field(sample, :samples_read_count), 0),
252
- "sampling_space_size" => integer(field(sample, :sampling_space_size), 0)
275
+ "samples_read_count" => nonnegative_integer(field(sample, :samples_read_count), "sampling counter"),
276
+ "sampling_space_size" => nonnegative_integer(field(sample, :sampling_space_size), "sampling counter")
253
277
  }
254
278
  end
255
279
  end
@@ -262,17 +286,18 @@ module AnalyticsOps
262
286
  next unless status
263
287
 
264
288
  result[name.to_s] = {
265
- "consumed" => integer(field(status, :consumed), 0),
266
- "remaining" => integer(field(status, :remaining), 0)
289
+ "consumed" => nonnegative_integer(field(status, :consumed), "quota counter"),
290
+ "remaining" => nonnegative_integer(field(status, :remaining), "quota counter")
267
291
  }
268
292
  end
269
293
  end
270
294
 
271
295
  def property_name(property_id)
272
- id = property_id.to_s
273
- raise ConfigurationError, "Invalid property ID" unless id.match?(/\A\d{1,50}\z/)
296
+ unless property_id.is_a?(String) && property_id.match?(/\A\d{1,50}\z/)
297
+ raise ConfigurationError, "Invalid property ID; expected a numeric string"
298
+ end
274
299
 
275
- "properties/#{id}"
300
+ "properties/#{property_id}"
276
301
  end
277
302
 
278
303
  def field(value, name)
@@ -288,26 +313,58 @@ module AnalyticsOps
288
313
  Array(field(value, name))
289
314
  end
290
315
 
291
- def optional_string(value)
292
- string = value.to_s
316
+ def optional_string(value, label)
317
+ return nil if value.nil?
318
+
319
+ string = remote_string(value, label)
293
320
  string.empty? ? nil : string
294
321
  end
295
322
 
296
- def integer(value, default)
297
- value.nil? ? default : Integer(value)
298
- rescue ArgumentError, TypeError
299
- default
323
+ def remote_string(value, label)
324
+ raise RemoteError, "Google Data API returned an invalid #{label}" unless value.is_a?(String)
325
+
326
+ string = value.encode(Encoding::UTF_8)
327
+ raise EncodingError unless string.valid_encoding?
328
+
329
+ string
330
+ rescue EncodingError
331
+ raise RemoteError, "Google Data API returned an invalid #{label}"
332
+ end
333
+
334
+ def nonnegative_integer(value, label)
335
+ return 0 if value.nil?
336
+ return value if value.is_a?(Integer) && !value.negative?
337
+
338
+ raise RemoteError, "Google Data API returned an invalid #{label}"
300
339
  end
301
340
 
302
341
  def response_row_count(response, default)
303
342
  value = field(response, :row_count)
304
- value.nil? ? default : Integer(value)
305
- rescue ArgumentError, TypeError
343
+ return default if value.nil?
344
+ return value if value.is_a?(Integer) && !value.negative?
345
+
306
346
  raise RemoteError, "Google Data API returned an invalid row count"
307
347
  end
308
348
 
309
- def boolean?(value)
310
- value == true
349
+ def metadata_boolean(value)
350
+ return false if value.nil?
351
+ return value if [true, false].include?(value)
352
+
353
+ raise RemoteError, "Google Data API returned an invalid metadata boolean"
354
+ end
355
+
356
+ def validate_transport(value)
357
+ transport = value.to_sym if value.respond_to?(:to_sym)
358
+ return transport if %i[grpc rest].include?(transport)
359
+
360
+ raise ConfigurationError, "transport must be grpc or rest"
361
+ end
362
+
363
+ def validate_timeout(value)
364
+ return nil if value.nil?
365
+ return value if [Integer, Float].any? { |type| value.is_a?(type) } && value.finite? && value.positive?
366
+
367
+ raise ConfigurationError, "timeout must be a finite positive number"
311
368
  end
312
369
  end
313
370
  end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AnalyticsOps
4
+ module Clients
5
+ # Shared fail-closed mapping from official-client transport failures to public gem errors.
6
+ module ErrorTranslation
7
+ NETWORK_FAILURE = /
8
+ Google::Cloud::|GRPC::|Gapic::|Faraday::|HTTP|SocketError|EOFError|OpenSSL::SSL::|
9
+ Errno::E(?:PIPE|CONNABORTED|CONNRESET|CONNREFUSED|HOSTUNREACH|NETUNREACH|ADDRNOTAVAIL)
10
+ /x
11
+
12
+ module_function
13
+
14
+ def call
15
+ yield
16
+ rescue AnalyticsOps::Error
17
+ raise
18
+ rescue StandardError => error
19
+ translated = error_class(error.class.name)
20
+ raise unless translated
21
+
22
+ raise translated, Redaction.message(error.message)
23
+ end
24
+
25
+ def error_class(name)
26
+ case name
27
+ when /Unauthenticated|Google::Auth|Signet::Authorization|Gapic::UniverseDomainMismatch/
28
+ AuthenticationError
29
+ when /PermissionDenied|Forbidden/
30
+ AuthorizationError
31
+ when /ResourceExhausted|TooManyRequests/
32
+ QuotaError
33
+ when /DeadlineExceeded|Timeout|ETIMEDOUT/
34
+ TimeoutError
35
+ when /InvalidArgument|FailedPrecondition|NotFound|AlreadyExists|Google::Protobuf::/
36
+ InvalidRequestError
37
+ when NETWORK_FAILURE
38
+ RemoteError
39
+ end
40
+ end
41
+ private_class_method :error_class
42
+ end
43
+ private_constant :ErrorTranslation
44
+ end
45
+ end
@@ -10,7 +10,7 @@ module AnalyticsOps
10
10
 
11
11
  def initialize(version:, profiles:)
12
12
  @version = version
13
- @profiles = Canonical.deep_freeze(profiles)
13
+ @profiles = Canonical.immutable(profiles)
14
14
  freeze
15
15
  end
16
16
 
@@ -9,6 +9,7 @@ module AnalyticsOps
9
9
  # Bounded safe-YAML reader with allowlisted environment interpolation.
10
10
  class Loader
11
11
  MAX_BYTES = 1_048_576
12
+ MAX_NESTING = 50
12
13
  VARIABLE = /\$\{([A-Z][A-Z0-9_]*)\}/
13
14
 
14
15
  def initialize(environment: ENV)
@@ -19,6 +20,8 @@ module AnalyticsOps
19
20
  source = read(path)
20
21
  raise ConfigurationError, "ERB is not allowed in Analytics Ops configuration" if source.include?("<%")
21
22
 
23
+ reject_duplicate_mapping_keys!(source)
24
+
22
25
  parsed = Psych.safe_load(
23
26
  source,
24
27
  permitted_classes: [],
@@ -30,7 +33,8 @@ module AnalyticsOps
30
33
 
31
34
  Validator.new(interpolate(parsed)).call
32
35
  rescue Psych::Exception => error
33
- raise ConfigurationError, "Invalid YAML in #{path}: #{error.message}"
36
+ raise ConfigurationError,
37
+ "Invalid YAML in #{Redaction.message(path)}: #{Redaction.message(error.message)}"
34
38
  end
35
39
 
36
40
  private
@@ -41,7 +45,8 @@ module AnalyticsOps
41
45
 
42
46
  contents
43
47
  rescue SystemCallError => error
44
- raise ConfigurationError, "Cannot read configuration #{path}: #{error.message}"
48
+ raise ConfigurationError,
49
+ "Cannot read configuration #{Redaction.message(path)}: #{Redaction.message(error.message)}"
45
50
  end
46
51
 
47
52
  def interpolate(value)
@@ -57,6 +62,36 @@ module AnalyticsOps
57
62
  end
58
63
  end
59
64
 
65
+ def reject_duplicate_mapping_keys!(source)
66
+ visit_yaml_node(Psych.parse_stream(source))
67
+ end
68
+
69
+ def visit_yaml_node(node, depth = 0)
70
+ raise ConfigurationError, "Configuration YAML nesting exceeds #{MAX_NESTING}" if depth > MAX_NESTING
71
+
72
+ if node.is_a?(Psych::Nodes::Mapping)
73
+ visit_yaml_mapping(node, depth)
74
+ elsif node.respond_to?(:children)
75
+ Array(node.children).each { |child| visit_yaml_node(child, depth + 1) }
76
+ end
77
+ end
78
+
79
+ def visit_yaml_mapping(node, depth)
80
+ keys = {}
81
+ node.children.each_slice(2) do |key, value|
82
+ if key.is_a?(Psych::Nodes::Scalar)
83
+ duplicate = keys.key?(key.value)
84
+ keys[key.value] = true
85
+ if duplicate
86
+ label = Redaction.message(key.value.inspect)
87
+ raise ConfigurationError, "Duplicate YAML mapping key #{label}"
88
+ end
89
+ end
90
+ visit_yaml_node(key, depth + 1)
91
+ visit_yaml_node(value, depth + 1)
92
+ end
93
+ end
94
+
60
95
  def interpolate_string(value)
61
96
  result = value.gsub(VARIABLE) do
62
97
  name = Regexp.last_match(1)
@@ -65,9 +100,7 @@ module AnalyticsOps
65
100
  @environment.fetch(name).to_s
66
101
  end
67
102
 
68
- if result.include?("${")
69
- raise EnvironmentVariableError, "Malformed environment interpolation in #{value.inspect}"
70
- end
103
+ raise EnvironmentVariableError, "Malformed environment interpolation" if result.include?("${")
71
104
 
72
105
  result
73
106
  end
@@ -69,11 +69,12 @@ module AnalyticsOps
69
69
  "required" => %w[event_data user_data reset_on_new_activity],
70
70
  "properties" => {
71
71
  "event_data" => { "$ref" => "#/$defs/retentionValue" },
72
- "user_data" => { "$ref" => "#/$defs/retentionValue" },
72
+ "user_data" => { "$ref" => "#/$defs/userRetentionValue" },
73
73
  "reset_on_new_activity" => { "type" => "boolean" }
74
74
  }
75
75
  },
76
76
  "retentionValue" => { "enum" => Configuration::Validator::RETENTION_VALUES },
77
+ "userRetentionValue" => { "enum" => Configuration::Validator::USER_RETENTION_VALUES },
77
78
  "googleSignals" => {
78
79
  "type" => "object", "additionalProperties" => false,
79
80
  "required" => %w[state experimental],
@@ -84,22 +85,64 @@ module AnalyticsOps
84
85
  "required" => %w[parameter_name display_name scope],
85
86
  "properties" => {
86
87
  "parameter_name" => { "type" => "string", "pattern" => "^[A-Za-z][A-Za-z0-9_]{0,39}$" },
87
- "display_name" => { "type" => "string", "minLength" => 1, "maxLength" => 82 },
88
- "description" => { "type" => "string", "maxLength" => 150 },
88
+ "display_name" => {
89
+ "type" => "string", "pattern" => "^[A-Za-z][A-Za-z0-9_ ]{0,81}$"
90
+ },
91
+ "description" => {
92
+ "type" => "string", "maxLength" => 150, "pattern" => "^[^\\u0000-\\u001F\\u007F]*$"
93
+ },
89
94
  "scope" => { "enum" => Configuration::Validator::DIMENSION_SCOPES },
90
95
  "disallow_ads_personalization" => { "type" => "boolean" }
91
- }
96
+ },
97
+ "allOf" => [
98
+ {
99
+ "if" => { "properties" => { "scope" => { "const" => "user" } } },
100
+ "then" => {
101
+ "properties" => {
102
+ "parameter_name" => { "type" => "string", "pattern" => "^[A-Za-z][A-Za-z0-9_]{0,23}$" }
103
+ }
104
+ }
105
+ },
106
+ {
107
+ "if" => {
108
+ "required" => ["disallow_ads_personalization"],
109
+ "properties" => { "disallow_ads_personalization" => { "const" => true } }
110
+ },
111
+ "then" => { "properties" => { "scope" => { "const" => "user" } } }
112
+ }
113
+ ]
92
114
  },
93
115
  "customMetric" => {
94
116
  "type" => "object", "additionalProperties" => false,
95
117
  "required" => %w[parameter_name display_name scope],
96
118
  "properties" => {
97
119
  "parameter_name" => { "type" => "string", "pattern" => "^[A-Za-z][A-Za-z0-9_]{0,39}$" },
98
- "display_name" => { "type" => "string", "minLength" => 1, "maxLength" => 82 },
99
- "description" => { "type" => "string", "maxLength" => 150 },
120
+ "display_name" => {
121
+ "type" => "string", "pattern" => "^[A-Za-z][A-Za-z0-9_ ]{0,81}$"
122
+ },
123
+ "description" => {
124
+ "type" => "string", "maxLength" => 150, "pattern" => "^[^\\u0000-\\u001F\\u007F]*$"
125
+ },
100
126
  "scope" => { "const" => "event" },
101
- "measurement_unit" => { "enum" => Configuration::Validator::METRIC_UNITS }
102
- }
127
+ "measurement_unit" => { "enum" => Configuration::Validator::METRIC_UNITS },
128
+ "restricted_metric_types" => {
129
+ "type" => "array", "uniqueItems" => true,
130
+ "items" => { "enum" => Configuration::Validator::RESTRICTED_METRIC_TYPES }
131
+ }
132
+ },
133
+ "allOf" => [
134
+ {
135
+ "if" => {
136
+ "required" => ["measurement_unit"],
137
+ "properties" => { "measurement_unit" => { "const" => "currency" } }
138
+ },
139
+ "then" => {
140
+ "required" => ["restricted_metric_types"],
141
+ "properties" => { "restricted_metric_types" => { "minItems" => 1 } }
142
+ },
143
+ "else" => { "properties" => { "restricted_metric_types" => { "maxItems" => 0 } } }
144
+ }
145
+ ]
103
146
  }
104
147
  }
105
148
  )
@@ -16,10 +16,14 @@ module AnalyticsOps
16
16
  RETENTION_KEYS = %w[event_data user_data reset_on_new_activity].freeze
17
17
  GOOGLE_SIGNALS_KEYS = %w[state experimental].freeze
18
18
  DIMENSION_KEYS = %w[parameter_name display_name description scope disallow_ads_personalization].freeze
19
- METRIC_KEYS = %w[parameter_name display_name description scope measurement_unit].freeze
19
+ METRIC_KEYS = %w[
20
+ parameter_name display_name description scope measurement_unit restricted_metric_types
21
+ ].freeze
20
22
  RETENTION_VALUES = %w[2_months 14_months 26_months 38_months 50_months].freeze
23
+ USER_RETENTION_VALUES = %w[2_months 14_months].freeze
21
24
  DIMENSION_SCOPES = %w[event user item].freeze
22
25
  METRIC_UNITS = %w[standard currency feet meters kilometers miles milliseconds seconds minutes hours].freeze
26
+ RESTRICTED_METRIC_TYPES = %w[cost_data revenue_data].freeze
23
27
  SECRET_KEY = /
24
28
  (?:\A|_)
25
29
  (?:
@@ -29,6 +33,7 @@ module AnalyticsOps
29
33
  (?:\z|_)
30
34
  /ix
31
35
  NAME = /\A[a-z][a-z0-9_]*\z/i
36
+ DISPLAY_NAME = /\A[A-Za-z][A-Za-z0-9_ ]{0,81}\z/
32
37
  EVENT_NAME = /\A[a-z][a-z0-9_]{0,39}\z/i
33
38
  ID = /\A\d{1,50}\z/
34
39
 
@@ -114,7 +119,7 @@ module AnalyticsOps
114
119
  retention = hash!(raw, path)
115
120
  exact_keys!(retention, RETENTION_KEYS, path)
116
121
  event_data = enum!(required(retention, "event_data", path), RETENTION_VALUES, "#{path}.event_data")
117
- user_data = enum!(required(retention, "user_data", path), RETENTION_VALUES, "#{path}.user_data")
122
+ user_data = enum!(required(retention, "user_data", path), USER_RETENTION_VALUES, "#{path}.user_data")
118
123
  reset = boolean!(required(retention, "reset_on_new_activity", path), "#{path}.reset_on_new_activity")
119
124
 
120
125
  { "event_data" => event_data, "user_data" => user_data, "reset_on_new_activity" => reset }
@@ -181,19 +186,41 @@ module AnalyticsOps
181
186
  exact_keys!(metric, METRIC_KEYS, item_path)
182
187
  scope = enum!(required(metric, "scope", item_path), ["event"], "#{item_path}.scope")
183
188
 
189
+ measurement_unit = enum!(metric.fetch("measurement_unit", "standard"), METRIC_UNITS,
190
+ "#{item_path}.measurement_unit")
191
+ restricted_types = validate_restricted_metric_types(
192
+ metric.fetch("restricted_metric_types", []), measurement_unit, item_path
193
+ )
194
+
184
195
  {
185
196
  "parameter_name" => parameter_name!(required(metric, "parameter_name", item_path), scope, item_path),
186
197
  "display_name" => display_name!(required(metric, "display_name", item_path), item_path),
187
198
  "description" => description!(metric.fetch("description", ""), item_path),
188
199
  "scope" => scope,
189
- "measurement_unit" => enum!(metric.fetch("measurement_unit", "standard"), METRIC_UNITS,
190
- "#{item_path}.measurement_unit")
200
+ "measurement_unit" => measurement_unit,
201
+ "restricted_metric_types" => restricted_types
191
202
  }
192
203
  end
193
204
 
194
205
  ensure_unique!(values, ["parameter_name"], path)
195
206
  end
196
207
 
208
+ def validate_restricted_metric_types(raw, measurement_unit, item_path)
209
+ path = "#{item_path}.restricted_metric_types"
210
+ values = array!(raw, path).map.with_index do |value, index|
211
+ enum!(value, RESTRICTED_METRIC_TYPES, "#{path}[#{index}]")
212
+ end
213
+ values = ensure_unique_strings!(values, path)
214
+ if measurement_unit == "currency" && values.empty?
215
+ raise ConfigurationError, "#{path} requires cost_data or revenue_data for a currency metric"
216
+ end
217
+ if measurement_unit != "currency" && !values.empty?
218
+ raise ConfigurationError, "#{path} is valid only for a currency metric"
219
+ end
220
+
221
+ values
222
+ end
223
+
197
224
  def validate_manual_requirements(raw, profile_path)
198
225
  path = "#{profile_path}.manual_requirements"
199
226
  values = array!(raw, path).map.with_index do |value, index|
@@ -218,8 +245,9 @@ module AnalyticsOps
218
245
 
219
246
  def display_name!(value, path)
220
247
  name = string!(value, "#{path}.display_name")
221
- if name.empty? || name.length > 82 || name.match?(/[\u0000-\u001f]/)
222
- raise ConfigurationError, "#{path}.display_name must contain 1 to 82 printable characters"
248
+ unless DISPLAY_NAME.match?(name)
249
+ raise ConfigurationError,
250
+ "#{path}.display_name must start with a letter and use only letters, numbers, spaces, or underscores"
223
251
  end
224
252
 
225
253
  name
@@ -239,7 +267,8 @@ module AnalyticsOps
239
267
 
240
268
  string = string!(value, path)
241
269
  uri = URI.parse(string)
242
- unless %w[http https].include?(uri.scheme) && uri.host && !uri.userinfo
270
+ unless string.length <= 2_048 && !string.match?(/[\u0000-\u001f\u007f]/) &&
271
+ %w[http https].include?(uri.scheme) && uri.host && !uri.userinfo
243
272
  raise ConfigurationError, "#{path} must be an absolute HTTP or HTTPS URI without credentials"
244
273
  end
245
274
 
@@ -308,7 +337,13 @@ module AnalyticsOps
308
337
  end
309
338
 
310
339
  def string!(value, path)
311
- return value if value.is_a?(String)
340
+ if value.is_a?(String)
341
+ if Redaction.credential_shaped?(value)
342
+ raise ConfigurationError, "Credential-shaped value at #{path} is forbidden"
343
+ end
344
+
345
+ return value
346
+ end
312
347
 
313
348
  raise ConfigurationError, "#{path} must be a string"
314
349
  end