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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +76 -0
- data/CONTRIBUTING.md +2 -1
- data/README.md +232 -41
- data/SECURITY.md +8 -8
- data/docs/api-support-matrix.md +9 -8
- data/docs/architecture.md +22 -4
- data/docs/authentication.md +112 -51
- data/docs/commands.md +71 -17
- data/docs/configuration-schema-v1.json +53 -7
- data/docs/configuration.md +33 -9
- data/docs/google-client-compatibility.md +10 -6
- data/docs/live-smoke-test.md +159 -0
- data/docs/plan-format.md +4 -0
- data/docs/plan-schema-v1.json +113 -22
- data/docs/rails.md +12 -3
- data/docs/reports.md +44 -6
- data/docs/safety.md +18 -6
- data/docs/troubleshooting.md +42 -13
- data/lib/analytics_ops/applier.rb +2 -1
- data/lib/analytics_ops/cli/options.rb +157 -0
- data/lib/analytics_ops/cli/presenter.rb +221 -0
- data/lib/analytics_ops/cli.rb +172 -201
- data/lib/analytics_ops/clients/admin.rb +130 -129
- data/lib/analytics_ops/clients/admin_normalization.rb +95 -0
- data/lib/analytics_ops/clients/data.rb +120 -63
- data/lib/analytics_ops/clients/error_translation.rb +45 -0
- data/lib/analytics_ops/configuration/document.rb +1 -1
- data/lib/analytics_ops/configuration/loader.rb +38 -5
- data/lib/analytics_ops/configuration/schema.rb +51 -8
- data/lib/analytics_ops/configuration/validator.rb +43 -8
- data/lib/analytics_ops/configuration/writer.rb +105 -0
- data/lib/analytics_ops/configuration.rb +1 -0
- data/lib/analytics_ops/connection.rb +93 -0
- data/lib/analytics_ops/desired_state.rb +2 -2
- data/lib/analytics_ops/plan.rb +94 -33
- data/lib/analytics_ops/planner.rb +10 -2
- data/lib/analytics_ops/rails/railtie.rb +13 -18
- data/lib/analytics_ops/redaction.rb +14 -9
- data/lib/analytics_ops/reports/catalog.rb +22 -5
- data/lib/analytics_ops/reports/definition.rb +74 -37
- data/lib/analytics_ops/reports/overview.rb +55 -0
- data/lib/analytics_ops/reports/overview_result.rb +54 -0
- data/lib/analytics_ops/reports/result.rb +38 -9
- data/lib/analytics_ops/reports.rb +2 -0
- data/lib/analytics_ops/resources.rb +2 -1
- data/lib/analytics_ops/service_account.rb +171 -0
- data/lib/analytics_ops/setup.rb +171 -0
- data/lib/analytics_ops/snapshot.rb +20 -5
- data/lib/analytics_ops/version.rb +1 -1
- data/lib/analytics_ops/workspace.rb +41 -12
- data/lib/analytics_ops.rb +4 -0
- data/lib/generators/analytics_ops/install_generator.rb +2 -0
- data/lib/generators/analytics_ops/templates/analytics_ops.yml +2 -15
- data/sig/analytics_ops.rbs +117 -10
- 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,
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
@
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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
|
|
204
|
-
|
|
205
|
-
|
|
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)
|
|
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
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
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" =>
|
|
252
|
-
"sampling_space_size" =>
|
|
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" =>
|
|
266
|
-
"remaining" =>
|
|
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
|
-
|
|
273
|
-
|
|
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/#{
|
|
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
|
-
|
|
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
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
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
|
-
|
|
305
|
-
|
|
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
|
|
310
|
-
|
|
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
|
|
@@ -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,
|
|
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,
|
|
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/
|
|
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" => {
|
|
88
|
-
|
|
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" => {
|
|
99
|
-
|
|
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[
|
|
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),
|
|
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" =>
|
|
190
|
-
|
|
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
|
-
|
|
222
|
-
raise ConfigurationError,
|
|
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
|
|
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
|
-
|
|
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
|