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
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "json"
|
|
5
|
+
require "securerandom"
|
|
6
|
+
|
|
7
|
+
module AnalyticsOps
|
|
8
|
+
module Configuration
|
|
9
|
+
# Safely creates the smallest valid configuration after interactive setup.
|
|
10
|
+
class Writer
|
|
11
|
+
# Immutable description of a configuration write or no-op.
|
|
12
|
+
class Result
|
|
13
|
+
attr_reader :path
|
|
14
|
+
|
|
15
|
+
def initialize(path:, created:)
|
|
16
|
+
raise ArgumentError, "created must be true or false" unless [true, false].include?(created)
|
|
17
|
+
|
|
18
|
+
@path = path.to_s.dup.freeze
|
|
19
|
+
@created = created
|
|
20
|
+
freeze
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def created?
|
|
24
|
+
@created
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def to_h
|
|
28
|
+
{ "path" => path, "created" => created? }
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def write_minimal(path, profile:, property_id:)
|
|
33
|
+
state = validated_state(profile, property_id)
|
|
34
|
+
expanded = File.expand_path(path)
|
|
35
|
+
return existing_result(expanded, state) if File.exist?(expanded)
|
|
36
|
+
|
|
37
|
+
FileUtils.mkdir_p(File.dirname(expanded))
|
|
38
|
+
temporary = temporary_path(expanded)
|
|
39
|
+
File.open(temporary, File::WRONLY | File::CREAT | File::EXCL, 0o644) do |file|
|
|
40
|
+
file.write(document(state))
|
|
41
|
+
file.flush
|
|
42
|
+
file.fsync
|
|
43
|
+
end
|
|
44
|
+
begin
|
|
45
|
+
File.link(temporary, expanded)
|
|
46
|
+
rescue Errno::EEXIST
|
|
47
|
+
raise ConfigurationError, "Configuration #{Redaction.message(path)} was created by another process"
|
|
48
|
+
end
|
|
49
|
+
Result.new(path: expanded, created: true)
|
|
50
|
+
rescue AnalyticsOps::Error
|
|
51
|
+
raise
|
|
52
|
+
rescue SystemCallError => error
|
|
53
|
+
raise ConfigurationError,
|
|
54
|
+
"Cannot write configuration #{Redaction.message(path)}: #{Redaction.message(error.message)}"
|
|
55
|
+
ensure
|
|
56
|
+
File.unlink(temporary) if temporary && File.exist?(temporary)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
def validated_state(profile, property_id)
|
|
62
|
+
unless property_id.is_a?(String)
|
|
63
|
+
raise ConfigurationError, "property_id must be a numeric identifier encoded as a string"
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
Validator.new(
|
|
67
|
+
"version" => 1,
|
|
68
|
+
"profiles" => { profile.to_s => { "property_id" => property_id } }
|
|
69
|
+
).call.profile(profile)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def existing_result(path, state)
|
|
73
|
+
configuration = Configuration.load(path)
|
|
74
|
+
unless configuration.profiles.key?(state.profile)
|
|
75
|
+
raise ConfigurationError,
|
|
76
|
+
"Configuration #{Redaction.message(path)} already exists without profile " \
|
|
77
|
+
"#{state.profile.inspect}; setup will not rewrite it"
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
existing = configuration.profile(state.profile)
|
|
81
|
+
unless existing.property_id == state.property_id
|
|
82
|
+
raise ConfigurationError,
|
|
83
|
+
"Profile #{state.profile.inspect} already targets property #{existing.property_id}; " \
|
|
84
|
+
"setup will not overwrite it"
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
Result.new(path:, created: false)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def document(state)
|
|
91
|
+
<<~YAML
|
|
92
|
+
version: 1
|
|
93
|
+
|
|
94
|
+
profiles:
|
|
95
|
+
#{state.profile}:
|
|
96
|
+
property_id: #{JSON.generate(state.property_id)}
|
|
97
|
+
YAML
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def temporary_path(path)
|
|
101
|
+
File.join(File.dirname(path), ".#{File.basename(path)}.#{Process.pid}.#{SecureRandom.hex(6)}.tmp")
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
@@ -6,6 +6,7 @@ require_relative "configuration/document"
|
|
|
6
6
|
require_relative "configuration/loader"
|
|
7
7
|
require_relative "configuration/validator"
|
|
8
8
|
require_relative "configuration/schema"
|
|
9
|
+
require_relative "configuration/writer"
|
|
9
10
|
|
|
10
11
|
module AnalyticsOps
|
|
11
12
|
# Strict versioned desired-state loading.
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AnalyticsOps
|
|
4
|
+
# Configuration-free Google connection used by discovery and setup.
|
|
5
|
+
class Connection
|
|
6
|
+
PROPERTY_ID = /\A\d{1,50}\z/
|
|
7
|
+
|
|
8
|
+
# Immutable proof that both APIs can read the selected property.
|
|
9
|
+
class Verification
|
|
10
|
+
attr_reader :property
|
|
11
|
+
|
|
12
|
+
def initialize(property:)
|
|
13
|
+
unless property.is_a?(Resources::Property)
|
|
14
|
+
raise ArgumentError, "property must be an AnalyticsOps::Resources::Property"
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
@property = property
|
|
18
|
+
freeze
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def to_h
|
|
22
|
+
{ "property" => property.to_h, "admin_api" => true, "data_api" => true }
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def initialize(admin: nil, data: nil, service_account: nil, transport: :grpc, timeout: nil, logger: nil)
|
|
27
|
+
unless service_account.nil? || service_account.is_a?(ServiceAccount)
|
|
28
|
+
raise ConfigurationError, "service_account must be an AnalyticsOps::ServiceAccount"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
@admin = admin
|
|
32
|
+
@data = data
|
|
33
|
+
@service_account = service_account
|
|
34
|
+
@transport = transport
|
|
35
|
+
@timeout = timeout
|
|
36
|
+
@logger = logger
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def discover
|
|
40
|
+
admin.discover
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def properties
|
|
44
|
+
admin.discover(include_streams: false)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def verify(property_id)
|
|
48
|
+
unless property_id.is_a?(String) && PROPERTY_ID.match?(property_id)
|
|
49
|
+
raise ConfigurationError, "Invalid property ID; expected a numeric string"
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
property = admin.property_access(property_id)
|
|
53
|
+
data.run(property.id, connectivity_definition)
|
|
54
|
+
Verification.new(property:)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
def admin
|
|
60
|
+
@admin ||= Clients::Admin.new(
|
|
61
|
+
service_account:,
|
|
62
|
+
access: :read,
|
|
63
|
+
transport: @transport,
|
|
64
|
+
timeout: @timeout,
|
|
65
|
+
logger: @logger
|
|
66
|
+
)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def data
|
|
70
|
+
@data ||= Clients::Data.new(
|
|
71
|
+
service_account:,
|
|
72
|
+
transport: @transport,
|
|
73
|
+
timeout: @timeout,
|
|
74
|
+
logger: @logger
|
|
75
|
+
)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def service_account
|
|
79
|
+
@service_account ||= ServiceAccount.load
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def connectivity_definition
|
|
83
|
+
@connectivity_definition ||= Reports::Definition.new(
|
|
84
|
+
name: "setup_connectivity",
|
|
85
|
+
kind: "standard",
|
|
86
|
+
dimensions: ["date"],
|
|
87
|
+
metrics: ["activeUsers"],
|
|
88
|
+
date_ranges: [{ "start_date" => "today", "end_date" => "today" }],
|
|
89
|
+
limit: 1
|
|
90
|
+
)
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
@@ -11,8 +11,8 @@ module AnalyticsOps
|
|
|
11
11
|
|
|
12
12
|
def initialize(profile:, property_id:, streams:, retention:, key_events:, custom_dimensions:, custom_metrics:,
|
|
13
13
|
manual_requirements:, google_signals:)
|
|
14
|
-
@profile = profile
|
|
15
|
-
@property_id = property_id
|
|
14
|
+
@profile = Canonical.immutable(profile)
|
|
15
|
+
@property_id = Canonical.immutable(property_id)
|
|
16
16
|
@streams = Canonical.immutable(streams)
|
|
17
17
|
@retention = Canonical.immutable(retention)
|
|
18
18
|
@key_events = Canonical.immutable(key_events)
|
data/lib/analytics_ops/plan.rb
CHANGED
|
@@ -24,15 +24,19 @@ module AnalyticsOps
|
|
|
24
24
|
CONTROL_CHARACTERS = /[\u0000-\u001f\u007f]/
|
|
25
25
|
SECRET_KEY = Configuration::Validator::SECRET_KEY
|
|
26
26
|
RETENTION_VALUES = Configuration::Validator::RETENTION_VALUES
|
|
27
|
+
USER_RETENTION_VALUES = Configuration::Validator::USER_RETENTION_VALUES
|
|
27
28
|
DIMENSION_SCOPES = Configuration::Validator::DIMENSION_SCOPES
|
|
28
29
|
METRIC_UNITS = Configuration::Validator::METRIC_UNITS
|
|
30
|
+
RESTRICTED_METRIC_TYPES = Configuration::Validator::RESTRICTED_METRIC_TYPES
|
|
29
31
|
|
|
30
32
|
RESOURCE_FIELDS = {
|
|
31
33
|
"data_stream" => %w[id name display_name type default_uri measurement_id],
|
|
32
34
|
"retention" => %w[name event_data user_data reset_on_new_activity],
|
|
33
35
|
"key_event" => %w[event_name counting_method],
|
|
34
36
|
"custom_dimension" => %w[parameter_name display_name description scope disallow_ads_personalization],
|
|
35
|
-
"custom_metric" => %w[
|
|
37
|
+
"custom_metric" => %w[
|
|
38
|
+
parameter_name display_name description scope measurement_unit restricted_metric_types
|
|
39
|
+
]
|
|
36
40
|
}.freeze
|
|
37
41
|
NAMED_DEFINITION_FIELDS = {
|
|
38
42
|
"custom_dimension" => ["name", *RESOURCE_FIELDS.fetch("custom_dimension")],
|
|
@@ -307,7 +311,8 @@ module AnalyticsOps
|
|
|
307
311
|
end
|
|
308
312
|
|
|
309
313
|
def self.printable_string(value, path, minimum: 1, maximum: 500)
|
|
310
|
-
unless value.is_a?(String) && value.length.between?(minimum, maximum) && !CONTROL_CHARACTERS.match?(value)
|
|
314
|
+
unless value.is_a?(String) && value.length.between?(minimum, maximum) && !CONTROL_CHARACTERS.match?(value) &&
|
|
315
|
+
!Redaction.credential_shaped?(value)
|
|
311
316
|
raise InvalidPlanError, "Invalid #{path}"
|
|
312
317
|
end
|
|
313
318
|
|
|
@@ -380,7 +385,7 @@ module AnalyticsOps
|
|
|
380
385
|
after = payload_hash(change.after, "#{path}.after")
|
|
381
386
|
fields = RESOURCE_FIELDS.fetch(change.resource_type)
|
|
382
387
|
self.class.exact_keys!(after, fields, "#{path}.after")
|
|
383
|
-
validate_resource_values!(change.resource_type, after, "#{path}.after", named: false)
|
|
388
|
+
validate_resource_values!(change.resource_type, after, "#{path}.after", named: false, desired: true)
|
|
384
389
|
validate_identity!(change, after, path)
|
|
385
390
|
end
|
|
386
391
|
|
|
@@ -388,8 +393,8 @@ module AnalyticsOps
|
|
|
388
393
|
fields = NAMED_DEFINITION_FIELDS.fetch(change.resource_type, RESOURCE_FIELDS.fetch(change.resource_type))
|
|
389
394
|
self.class.exact_keys!(before, fields, "#{path}.before")
|
|
390
395
|
self.class.exact_keys!(after, fields, "#{path}.after")
|
|
391
|
-
validate_resource_values!(change.resource_type, before, "#{path}.before", named: true)
|
|
392
|
-
validate_resource_values!(change.resource_type, after, "#{path}.after", named: true)
|
|
396
|
+
validate_resource_values!(change.resource_type, before, "#{path}.before", named: true, desired: false)
|
|
397
|
+
validate_resource_values!(change.resource_type, after, "#{path}.after", named: true, desired: true)
|
|
393
398
|
validate_identity!(change, after, path)
|
|
394
399
|
validate_immutable_fields!(change.resource_type, before, after, path)
|
|
395
400
|
validate_property_resource_name!(change.resource_type, before, path)
|
|
@@ -402,40 +407,81 @@ module AnalyticsOps
|
|
|
402
407
|
hash
|
|
403
408
|
end
|
|
404
409
|
|
|
405
|
-
def validate_resource_values!(resource_type, payload, path, named:)
|
|
410
|
+
def validate_resource_values!(resource_type, payload, path, named:, desired:)
|
|
406
411
|
case resource_type
|
|
407
412
|
when "data_stream"
|
|
408
|
-
|
|
409
|
-
resource_name!(payload.fetch("name"), "dataStreams", "#{path}.name")
|
|
410
|
-
text!(payload.fetch("display_name"), "#{path}.display_name", minimum: 0, maximum: 100)
|
|
411
|
-
enum!(payload.fetch("type"), %w[web android ios unspecified], "#{path}.type")
|
|
412
|
-
optional_uri!(payload.fetch("default_uri"), "#{path}.default_uri")
|
|
413
|
-
optional_text!(payload.fetch("measurement_id"), "#{path}.measurement_id", maximum: 64)
|
|
413
|
+
validate_data_stream_values!(payload, path, desired:)
|
|
414
414
|
when "retention"
|
|
415
|
-
|
|
416
|
-
enum!(payload.fetch("event_data"), RETENTION_VALUES, "#{path}.event_data")
|
|
417
|
-
enum!(payload.fetch("user_data"), RETENTION_VALUES, "#{path}.user_data")
|
|
418
|
-
boolean!(payload.fetch("reset_on_new_activity"), "#{path}.reset_on_new_activity")
|
|
415
|
+
validate_retention_values!(payload, path)
|
|
419
416
|
when "key_event"
|
|
420
|
-
|
|
421
|
-
enum!(payload.fetch("counting_method"), %w[once_per_event once_per_session], "#{path}.counting_method")
|
|
417
|
+
validate_key_event_values!(payload, path)
|
|
422
418
|
when "custom_dimension"
|
|
423
|
-
|
|
424
|
-
parameter_name!(payload.fetch("parameter_name"), "#{path}.parameter_name")
|
|
425
|
-
text!(payload.fetch("display_name"), "#{path}.display_name", minimum: 1, maximum: 82)
|
|
426
|
-
text!(payload.fetch("description"), "#{path}.description", minimum: 0, maximum: 150)
|
|
427
|
-
enum!(payload.fetch("scope"), DIMENSION_SCOPES, "#{path}.scope")
|
|
428
|
-
boolean!(payload.fetch("disallow_ads_personalization"), "#{path}.disallow_ads_personalization")
|
|
419
|
+
validate_custom_dimension_values!(payload, path, named:, desired:)
|
|
429
420
|
when "custom_metric"
|
|
430
|
-
|
|
431
|
-
parameter_name!(payload.fetch("parameter_name"), "#{path}.parameter_name")
|
|
432
|
-
text!(payload.fetch("display_name"), "#{path}.display_name", minimum: 1, maximum: 82)
|
|
433
|
-
text!(payload.fetch("description"), "#{path}.description", minimum: 0, maximum: 150)
|
|
434
|
-
enum!(payload.fetch("scope"), ["event"], "#{path}.scope")
|
|
435
|
-
enum!(payload.fetch("measurement_unit"), METRIC_UNITS, "#{path}.measurement_unit")
|
|
421
|
+
validate_custom_metric_values!(payload, path, named:, desired:)
|
|
436
422
|
end
|
|
437
423
|
end
|
|
438
424
|
|
|
425
|
+
def validate_data_stream_values!(payload, path, desired:)
|
|
426
|
+
id_string!(payload.fetch("id"), "#{path}.id")
|
|
427
|
+
resource_name!(payload.fetch("name"), "dataStreams", "#{path}.name")
|
|
428
|
+
text!(payload.fetch("display_name"), "#{path}.display_name", minimum: 0, maximum: 100)
|
|
429
|
+
enum!(payload.fetch("type"), ["web"], "#{path}.type")
|
|
430
|
+
optional_uri!(payload.fetch("default_uri"), "#{path}.default_uri")
|
|
431
|
+
raise InvalidPlanError, "Invalid #{path}.default_uri" if desired && payload.fetch("default_uri").nil?
|
|
432
|
+
|
|
433
|
+
optional_text!(payload.fetch("measurement_id"), "#{path}.measurement_id", maximum: 64)
|
|
434
|
+
end
|
|
435
|
+
|
|
436
|
+
def validate_retention_values!(payload, path)
|
|
437
|
+
resource_name!(payload.fetch("name"), "dataRetentionSettings", "#{path}.name", singleton: true)
|
|
438
|
+
enum!(payload.fetch("event_data"), RETENTION_VALUES, "#{path}.event_data")
|
|
439
|
+
enum!(payload.fetch("user_data"), USER_RETENTION_VALUES, "#{path}.user_data")
|
|
440
|
+
boolean!(payload.fetch("reset_on_new_activity"), "#{path}.reset_on_new_activity")
|
|
441
|
+
end
|
|
442
|
+
|
|
443
|
+
def validate_key_event_values!(payload, path)
|
|
444
|
+
event_name!(payload.fetch("event_name"), "#{path}.event_name")
|
|
445
|
+
enum!(payload.fetch("counting_method"), %w[once_per_event once_per_session], "#{path}.counting_method")
|
|
446
|
+
end
|
|
447
|
+
|
|
448
|
+
def validate_custom_dimension_values!(payload, path, named:, desired:)
|
|
449
|
+
resource_name!(payload.fetch("name"), "customDimensions", "#{path}.name") if named
|
|
450
|
+
scope = payload.fetch("scope")
|
|
451
|
+
maximum = scope == "user" ? 24 : 40
|
|
452
|
+
parameter_name!(payload.fetch("parameter_name"), "#{path}.parameter_name", maximum:)
|
|
453
|
+
display_name!(payload.fetch("display_name"), "#{path}.display_name", desired:)
|
|
454
|
+
text!(payload.fetch("description"), "#{path}.description", minimum: 0, maximum: 150)
|
|
455
|
+
enum!(scope, DIMENSION_SCOPES, "#{path}.scope")
|
|
456
|
+
disallow_ads = payload.fetch("disallow_ads_personalization")
|
|
457
|
+
boolean!(disallow_ads, "#{path}.disallow_ads_personalization")
|
|
458
|
+
return unless disallow_ads && scope != "user"
|
|
459
|
+
|
|
460
|
+
raise InvalidPlanError, "#{path}.disallow_ads_personalization is valid only for user scope"
|
|
461
|
+
end
|
|
462
|
+
|
|
463
|
+
def validate_custom_metric_values!(payload, path, named:, desired:)
|
|
464
|
+
resource_name!(payload.fetch("name"), "customMetrics", "#{path}.name") if named
|
|
465
|
+
parameter_name!(payload.fetch("parameter_name"), "#{path}.parameter_name")
|
|
466
|
+
display_name!(payload.fetch("display_name"), "#{path}.display_name", desired:)
|
|
467
|
+
text!(payload.fetch("description"), "#{path}.description", minimum: 0, maximum: 150)
|
|
468
|
+
enum!(payload.fetch("scope"), ["event"], "#{path}.scope")
|
|
469
|
+
measurement_unit = payload.fetch("measurement_unit")
|
|
470
|
+
enum!(measurement_unit, METRIC_UNITS, "#{path}.measurement_unit")
|
|
471
|
+
restricted_types = payload.fetch("restricted_metric_types")
|
|
472
|
+
enum_array!(restricted_types, RESTRICTED_METRIC_TYPES, "#{path}.restricted_metric_types")
|
|
473
|
+
validate_restricted_metric_types!(measurement_unit, restricted_types, path)
|
|
474
|
+
end
|
|
475
|
+
|
|
476
|
+
def validate_restricted_metric_types!(measurement_unit, restricted_types, path)
|
|
477
|
+
if measurement_unit == "currency" && restricted_types.empty?
|
|
478
|
+
raise InvalidPlanError, "#{path}.restricted_metric_types is required for a currency metric"
|
|
479
|
+
end
|
|
480
|
+
return if measurement_unit == "currency" || restricted_types.empty?
|
|
481
|
+
|
|
482
|
+
raise InvalidPlanError, "#{path}.restricted_metric_types is valid only for a currency metric"
|
|
483
|
+
end
|
|
484
|
+
|
|
439
485
|
def validate_identity!(change, payload, path)
|
|
440
486
|
expected = case change.resource_type
|
|
441
487
|
when "data_stream"
|
|
@@ -456,7 +502,7 @@ module AnalyticsOps
|
|
|
456
502
|
|
|
457
503
|
def validate_immutable_fields!(resource_type, before, after, path)
|
|
458
504
|
mutable = MUTABLE_FIELDS.fetch(resource_type)
|
|
459
|
-
changed = before.keys.reject { |key| before[key] == after[key] }
|
|
505
|
+
changed = (before.keys | after.keys).reject { |key| before[key] == after[key] }
|
|
460
506
|
forbidden = changed - mutable
|
|
461
507
|
raise InvalidPlanError, "#{path} changes immutable field #{forbidden.first}" unless forbidden.empty?
|
|
462
508
|
raise InvalidPlanError, "#{path} update does not change any mutable field" if changed.empty?
|
|
@@ -516,14 +562,29 @@ module AnalyticsOps
|
|
|
516
562
|
self.class.pattern_string(value, path, EVENT_NAME)
|
|
517
563
|
end
|
|
518
564
|
|
|
519
|
-
def parameter_name!(value, path)
|
|
520
|
-
self.class.pattern_string(value, path, PARAMETER_NAME)
|
|
565
|
+
def parameter_name!(value, path, maximum: 40)
|
|
566
|
+
self.class.pattern_string(value, path, PARAMETER_NAME, maximum:)
|
|
521
567
|
end
|
|
522
568
|
|
|
523
569
|
def text!(value, path, minimum:, maximum:)
|
|
524
570
|
self.class.printable_string(value, path, minimum:, maximum:)
|
|
525
571
|
end
|
|
526
572
|
|
|
573
|
+
def display_name!(value, path, desired:)
|
|
574
|
+
text!(value, path, minimum: 1, maximum: 82)
|
|
575
|
+
return unless desired && !Configuration::Validator::DISPLAY_NAME.match?(value)
|
|
576
|
+
|
|
577
|
+
raise InvalidPlanError, "Invalid #{path}"
|
|
578
|
+
end
|
|
579
|
+
|
|
580
|
+
def enum_array!(value, allowed, path)
|
|
581
|
+
self.class.array(value, path)
|
|
582
|
+
unless value.all? { |item| item.is_a?(String) && allowed.include?(item) }
|
|
583
|
+
raise InvalidPlanError, "Invalid #{path}"
|
|
584
|
+
end
|
|
585
|
+
raise InvalidPlanError, "Duplicate values in #{path}" unless value.uniq.length == value.length
|
|
586
|
+
end
|
|
587
|
+
|
|
527
588
|
def optional_text!(value, path, maximum:)
|
|
528
589
|
return if value.nil?
|
|
529
590
|
|
|
@@ -90,6 +90,13 @@ module AnalyticsOps
|
|
|
90
90
|
end
|
|
91
91
|
|
|
92
92
|
current = remote.to_h
|
|
93
|
+
unless Plan::RETENTION_VALUES.include?(current.fetch("event_data")) &&
|
|
94
|
+
Plan::USER_RETENTION_VALUES.include?(current.fetch("user_data"))
|
|
95
|
+
finding("drift", "retention_unsupported", "property:#{@desired.property_id}:retention",
|
|
96
|
+
"Google returned a retention duration that Analytics Ops cannot safely change")
|
|
97
|
+
return
|
|
98
|
+
end
|
|
99
|
+
|
|
93
100
|
after = current.merge(desired)
|
|
94
101
|
return if equivalent?(current, after, %w[event_data user_data reset_on_new_activity])
|
|
95
102
|
|
|
@@ -153,9 +160,10 @@ module AnalyticsOps
|
|
|
153
160
|
|
|
154
161
|
before = current.to_h
|
|
155
162
|
if before.fetch("scope") != desired.fetch("scope") ||
|
|
156
|
-
before.fetch("measurement_unit") != desired.fetch("measurement_unit")
|
|
163
|
+
before.fetch("measurement_unit") != desired.fetch("measurement_unit") ||
|
|
164
|
+
before.fetch("restricted_metric_types") != desired.fetch("restricted_metric_types")
|
|
157
165
|
finding("drift", "immutable_metric_conflict", "metric:#{identity}",
|
|
158
|
-
"Custom metric scope or
|
|
166
|
+
"Custom metric scope, unit, or restricted-data classification differs and will not be recreated")
|
|
159
167
|
next
|
|
160
168
|
end
|
|
161
169
|
|
|
@@ -26,30 +26,25 @@ module AnalyticsOps
|
|
|
26
26
|
|
|
27
27
|
# Optional Rails hooks. Defining this class performs no Google API calls.
|
|
28
28
|
class Railtie < Rails::Railtie
|
|
29
|
+
OPERATOR_TASKS = {
|
|
30
|
+
doctor: "Validate Analytics Ops configuration, credentials, and API access",
|
|
31
|
+
audit: "Audit configured Google Analytics state without changing it",
|
|
32
|
+
plan: "Write a deterministic Analytics Ops plan",
|
|
33
|
+
verify: "Verify that managed Google Analytics state converges",
|
|
34
|
+
overview: "Show the selected property's Analytics overview"
|
|
35
|
+
}.freeze
|
|
36
|
+
|
|
29
37
|
generators do
|
|
30
38
|
require_relative "../../generators/analytics_ops/install_generator"
|
|
31
39
|
end
|
|
32
40
|
|
|
33
41
|
rake_tasks do
|
|
34
42
|
namespace :analytics do
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
desc "Audit configured Google Analytics state without changing it"
|
|
41
|
-
task audit: :environment do
|
|
42
|
-
RailsTasks.run("audit")
|
|
43
|
-
end
|
|
44
|
-
|
|
45
|
-
desc "Write a deterministic Analytics Ops plan"
|
|
46
|
-
task plan: :environment do
|
|
47
|
-
RailsTasks.run("plan")
|
|
48
|
-
end
|
|
49
|
-
|
|
50
|
-
desc "Verify that managed Google Analytics state converges"
|
|
51
|
-
task verify: :environment do
|
|
52
|
-
RailsTasks.run("verify")
|
|
43
|
+
OPERATOR_TASKS.each do |name, description|
|
|
44
|
+
desc description
|
|
45
|
+
task name => :environment do
|
|
46
|
+
RailsTasks.run(name.to_s)
|
|
47
|
+
end
|
|
53
48
|
end
|
|
54
49
|
|
|
55
50
|
desc "Run a built-in report: rake analytics:report[NAME]"
|
|
@@ -13,20 +13,25 @@ module AnalyticsOps
|
|
|
13
13
|
access[_ -]?token|refresh[_ -]?token|client[_ -]?(?:id|secret)|
|
|
14
14
|
private[_ -]?key|password|api[_ -]?key|credentials?
|
|
15
15
|
)
|
|
16
|
-
|
|
16
|
+
["']?\s*(?:[:=]\s*|=>\s*)(?:["'][^"'\r\n]*["']|[^\s,;\r\n}]+)
|
|
17
17
|
/ix
|
|
18
|
-
CONTROL_CHARACTERS = /[\u0000-\
|
|
18
|
+
CONTROL_CHARACTERS = /[\u0000-\u001f\u007f]/
|
|
19
19
|
|
|
20
20
|
module_function
|
|
21
21
|
|
|
22
|
+
def credential_shaped?(value)
|
|
23
|
+
text = value.to_s.encode(Encoding::UTF_8, invalid: :replace, undef: :replace, replace: "?")
|
|
24
|
+
[PRIVATE_KEY, AUTHORIZATION, BEARER, SECRET_ASSIGNMENT].any? { |pattern| pattern.match?(text) }
|
|
25
|
+
end
|
|
26
|
+
|
|
22
27
|
def message(value)
|
|
23
|
-
value.to_s
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
28
|
+
text = value.to_s.encode(Encoding::UTF_8, invalid: :replace, undef: :replace, replace: "?")
|
|
29
|
+
text.gsub(PRIVATE_KEY, "[REDACTED]")
|
|
30
|
+
.gsub(AUTHORIZATION, "Authorization: [REDACTED]")
|
|
31
|
+
.gsub(BEARER, "Bearer [REDACTED]")
|
|
32
|
+
.gsub(SECRET_ASSIGNMENT) { "#{Regexp.last_match(1)}=[REDACTED]" }
|
|
33
|
+
.gsub(CONTROL_CHARACTERS, "?")
|
|
34
|
+
.slice(0, 1_000)
|
|
30
35
|
end
|
|
31
36
|
end
|
|
32
37
|
|
|
@@ -4,7 +4,13 @@ module AnalyticsOps
|
|
|
4
4
|
module Reports
|
|
5
5
|
# Small built-in catalog focused on acquisition and calculator outcomes.
|
|
6
6
|
module Catalog
|
|
7
|
-
STANDARD_DATE_RANGE =
|
|
7
|
+
STANDARD_DATE_RANGE = Canonical.immutable(
|
|
8
|
+
[{ "start_date" => "28daysAgo", "end_date" => "yesterday" }]
|
|
9
|
+
)
|
|
10
|
+
ALIASES = {
|
|
11
|
+
"traffic" => "traffic_acquisition",
|
|
12
|
+
"landing-pages" => "landing_pages"
|
|
13
|
+
}.freeze
|
|
8
14
|
CALCULATOR = "customEvent:calculator_slug"
|
|
9
15
|
EVENT_FILTER = lambda do |value, match_type = "exact"|
|
|
10
16
|
{ "field" => "eventName", "match_type" => match_type, "value" => value }
|
|
@@ -73,7 +79,7 @@ module AnalyticsOps
|
|
|
73
79
|
name: "realtime_events",
|
|
74
80
|
kind: "realtime",
|
|
75
81
|
dimensions: %w[eventName],
|
|
76
|
-
metrics: %w[eventCount
|
|
82
|
+
metrics: %w[eventCount],
|
|
77
83
|
order_bys: [{ "metric" => "eventCount", "desc" => true }],
|
|
78
84
|
limit: 100
|
|
79
85
|
)
|
|
@@ -82,8 +88,11 @@ module AnalyticsOps
|
|
|
82
88
|
module_function
|
|
83
89
|
|
|
84
90
|
def fetch(name, kind: nil)
|
|
85
|
-
|
|
86
|
-
|
|
91
|
+
requested = name.to_s
|
|
92
|
+
canonical = ALIASES.fetch(requested, requested)
|
|
93
|
+
definition = DEFINITIONS.fetch(canonical) do
|
|
94
|
+
available = (names + ALIASES.keys).sort.join(", ")
|
|
95
|
+
raise InvalidRequestError, "Unknown report #{name.inspect}; available reports: #{available}"
|
|
87
96
|
end
|
|
88
97
|
if kind && definition.kind != kind.to_s
|
|
89
98
|
raise InvalidRequestError, "Report #{name} is #{definition.kind}, not #{kind}"
|
|
@@ -93,7 +102,15 @@ module AnalyticsOps
|
|
|
93
102
|
end
|
|
94
103
|
|
|
95
104
|
def names
|
|
96
|
-
DEFINITIONS.keys.sort
|
|
105
|
+
DEFINITIONS.keys.sort.freeze
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def aliases
|
|
109
|
+
ALIASES.dup.freeze
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def overview
|
|
113
|
+
Overview::DEFINITIONS
|
|
97
114
|
end
|
|
98
115
|
end
|
|
99
116
|
end
|