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
@@ -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.freeze
15
- @property_id = property_id.freeze
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)
@@ -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[parameter_name display_name description scope measurement_unit]
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
- id_string!(payload.fetch("id"), "#{path}.id")
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
- resource_name!(payload.fetch("name"), "dataRetentionSettings", "#{path}.name", singleton: true)
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
- event_name!(payload.fetch("event_name"), "#{path}.event_name")
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
- resource_name!(payload.fetch("name"), "customDimensions", "#{path}.name") if named
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
- resource_name!(payload.fetch("name"), "customMetrics", "#{path}.name") if named
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 measurement unit differs and will not be recreated automatically")
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
- desc "Validate Analytics Ops configuration, credentials, and API access"
36
- task doctor: :environment do
37
- RailsTasks.run("doctor")
38
- end
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
- \s*([:=]\s*|=>\s*)["']?[^\s,"';}]+
16
+ ["']?\s*(?:[:=]\s*|=>\s*)(?:["'][^"'\r\n]*["']|[^\s,;\r\n}]+)
17
17
  /ix
18
- CONTROL_CHARACTERS = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/
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
- .gsub(PRIVATE_KEY, "[REDACTED]")
25
- .gsub(AUTHORIZATION, "Authorization: [REDACTED]")
26
- .gsub(BEARER, "Bearer [REDACTED]")
27
- .gsub(SECRET_ASSIGNMENT) { "#{Regexp.last_match(1)}=[REDACTED]" }
28
- .gsub(CONTROL_CHARACTERS, "?")
29
- .slice(0, 1_000)
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 = [{ "start_date" => "28daysAgo", "end_date" => "yesterday" }].freeze
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 activeUsers],
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
- definition = DEFINITIONS.fetch(name.to_s) do
86
- raise InvalidRequestError, "Unknown report #{name.inspect}; available reports: #{names.join(", ")}"
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