analytics_ops 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +108 -0
  3. data/CONTRIBUTING.md +1 -0
  4. data/README.md +141 -13
  5. data/SECURITY.md +11 -2
  6. data/docs/ai-connections.md +192 -0
  7. data/docs/api-support-matrix.md +25 -8
  8. data/docs/architecture.md +78 -17
  9. data/docs/authentication.md +53 -3
  10. data/docs/bigquery.md +128 -0
  11. data/docs/commands.md +133 -12
  12. data/docs/configuration-schema-v1.json +608 -50
  13. data/docs/configuration.md +118 -5
  14. data/docs/google-client-compatibility.md +21 -1
  15. data/docs/governance.md +93 -0
  16. data/docs/health.md +82 -0
  17. data/docs/live-smoke-test.md +13 -10
  18. data/docs/rails.md +25 -9
  19. data/docs/reports.md +165 -3
  20. data/docs/safety.md +37 -5
  21. data/docs/troubleshooting.md +81 -7
  22. data/lib/analytics_ops/bigquery/definition.rb +173 -0
  23. data/lib/analytics_ops/bigquery.rb +4 -0
  24. data/lib/analytics_ops/cli/local_commands.rb +80 -0
  25. data/lib/analytics_ops/cli/options.rb +282 -9
  26. data/lib/analytics_ops/cli/presenter.rb +263 -28
  27. data/lib/analytics_ops/cli/presenter_csv.rb +92 -0
  28. data/lib/analytics_ops/cli/reporting_commands.rb +129 -0
  29. data/lib/analytics_ops/cli/setup_commands.rb +91 -0
  30. data/lib/analytics_ops/cli.rb +121 -44
  31. data/lib/analytics_ops/clients/admin.rb +28 -2
  32. data/lib/analytics_ops/clients/admin_governance.rb +95 -0
  33. data/lib/analytics_ops/clients/admin_governance_normalization.rb +90 -0
  34. data/lib/analytics_ops/clients/admin_resource_normalization.rb +61 -0
  35. data/lib/analytics_ops/clients/bigquery.rb +313 -0
  36. data/lib/analytics_ops/clients/data.rb +147 -54
  37. data/lib/analytics_ops/clients/data_result_normalization.rb +55 -0
  38. data/lib/analytics_ops/clients/error_translation.rb +13 -1
  39. data/lib/analytics_ops/configuration/schema.rb +175 -1
  40. data/lib/analytics_ops/configuration/validator.rb +45 -7
  41. data/lib/analytics_ops/configuration/validator_experimental.rb +136 -0
  42. data/lib/analytics_ops/configuration/validator_reporting.rb +93 -0
  43. data/lib/analytics_ops/configuration/writer.rb +112 -7
  44. data/lib/analytics_ops/desired_state.rb +28 -3
  45. data/lib/analytics_ops/errors.rb +33 -1
  46. data/lib/analytics_ops/funnels.rb +58 -0
  47. data/lib/analytics_ops/governance.rb +363 -0
  48. data/lib/analytics_ops/health.rb +310 -0
  49. data/lib/analytics_ops/mcp_server/custom_report_tool.rb +101 -0
  50. data/lib/analytics_ops/mcp_server/discovery_tools.rb +79 -0
  51. data/lib/analytics_ops/mcp_server/experimental_tools.rb +89 -0
  52. data/lib/analytics_ops/mcp_server/health_tools.rb +80 -0
  53. data/lib/analytics_ops/mcp_server/overview_tools.rb +49 -0
  54. data/lib/analytics_ops/mcp_server/standard_report_tool.rb +53 -0
  55. data/lib/analytics_ops/mcp_server.rb +402 -0
  56. data/lib/analytics_ops/portfolio.rb +216 -0
  57. data/lib/analytics_ops/rails/railtie.rb +19 -8
  58. data/lib/analytics_ops/redaction.rb +10 -6
  59. data/lib/analytics_ops/reports/csv_export.rb +91 -0
  60. data/lib/analytics_ops/reports/definition.rb +152 -14
  61. data/lib/analytics_ops/reports/metadata.rb +158 -0
  62. data/lib/analytics_ops/reports/period.rb +104 -0
  63. data/lib/analytics_ops/reports/result.rb +8 -3
  64. data/lib/analytics_ops/reports.rb +3 -0
  65. data/lib/analytics_ops/service_account.rb +321 -29
  66. data/lib/analytics_ops/setup.rb +31 -7
  67. data/lib/analytics_ops/version.rb +1 -1
  68. data/lib/analytics_ops/workspace.rb +222 -10
  69. data/lib/analytics_ops.rb +7 -2
  70. data/lib/generators/analytics_ops/templates/analytics_ops.yml +1 -1
  71. data/sig/analytics_ops.rbs +494 -9
  72. metadata +60 -1
@@ -3,6 +3,7 @@
3
3
  require "fileutils"
4
4
  require "json"
5
5
  require "securerandom"
6
+ require "tmpdir"
6
7
 
7
8
  module AnalyticsOps
8
9
  module Configuration
@@ -12,11 +13,14 @@ module AnalyticsOps
12
13
  class Result
13
14
  attr_reader :path
14
15
 
15
- def initialize(path:, created:)
16
- raise ArgumentError, "created must be true or false" unless [true, false].include?(created)
16
+ def initialize(path:, created:, updated: false)
17
+ unless [true, false].include?(created) && [true, false].include?(updated) && !(created && updated)
18
+ raise ArgumentError, "created and updated must be distinct booleans"
19
+ end
17
20
 
18
21
  @path = path.to_s.dup.freeze
19
22
  @created = created
23
+ @updated = updated
20
24
  freeze
21
25
  end
22
26
 
@@ -24,15 +28,23 @@ module AnalyticsOps
24
28
  @created
25
29
  end
26
30
 
31
+ def updated?
32
+ @updated
33
+ end
34
+
35
+ def changed?
36
+ created? || updated?
37
+ end
38
+
27
39
  def to_h
28
- { "path" => path, "created" => created? }
40
+ { "path" => path, "created" => created?, "updated" => updated? }
29
41
  end
30
42
  end
31
43
 
32
44
  def write_minimal(path, profile:, property_id:)
33
45
  state = validated_state(profile, property_id)
34
46
  expanded = File.expand_path(path)
35
- return existing_result(expanded, state) if File.exist?(expanded)
47
+ return existing_result(File.realpath(expanded), state) if File.exist?(expanded)
36
48
 
37
49
  FileUtils.mkdir_p(File.dirname(expanded))
38
50
  temporary = temporary_path(expanded)
@@ -70,11 +82,12 @@ module AnalyticsOps
70
82
  end
71
83
 
72
84
  def existing_result(path, state)
85
+ source = read_source(path)
73
86
  configuration = Configuration.load(path)
74
87
  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"
88
+ updated = append_profile(source, state)
89
+ atomic_replace(path, original: source, replacement: updated)
90
+ return Result.new(path:, created: false, updated: true)
78
91
  end
79
92
 
80
93
  existing = configuration.profile(state.profile)
@@ -85,6 +98,12 @@ module AnalyticsOps
85
98
  end
86
99
 
87
100
  Result.new(path:, created: false)
101
+ rescue EnvironmentVariableError
102
+ replacement = replace_generator_placeholder(source, state)
103
+ raise unless replacement
104
+
105
+ atomic_replace(path, original: source, replacement:)
106
+ Result.new(path:, created: false, updated: true)
88
107
  end
89
108
 
90
109
  def document(state)
@@ -100,6 +119,92 @@ module AnalyticsOps
100
119
  def temporary_path(path)
101
120
  File.join(File.dirname(path), ".#{File.basename(path)}.#{Process.pid}.#{SecureRandom.hex(6)}.tmp")
102
121
  end
122
+
123
+ def read_source(path)
124
+ source = File.binread(path, Loader::MAX_BYTES + 1)
125
+ if source.bytesize > Loader::MAX_BYTES
126
+ raise ConfigurationError, "Configuration exceeds #{Loader::MAX_BYTES} bytes"
127
+ end
128
+
129
+ source
130
+ end
131
+
132
+ def append_profile(source, state)
133
+ profiles = profiles_mapping(source)
134
+ indentation = " " * profiles.children.first.start_column
135
+ snippet = "#{indentation}#{state.profile}:\n" \
136
+ "#{indentation} property_id: #{JSON.generate(state.property_id)}\n"
137
+ lines = source.lines
138
+ index = profiles.end_line
139
+ complete_final_line(lines, index)
140
+ lines.insert(index, snippet)
141
+ lines.join
142
+ rescue Psych::Exception => error
143
+ raise ConfigurationError, "Configuration cannot be updated safely: #{Redaction.message(error.message)}"
144
+ end
145
+
146
+ def profiles_mapping(source)
147
+ root = Psych.parse(source)&.root
148
+ raise ConfigurationError, "Configuration root must be a mapping" unless root.is_a?(Psych::Nodes::Mapping)
149
+
150
+ profiles = root.children.each_slice(2).find do |key, _value|
151
+ key.is_a?(Psych::Nodes::Scalar) && key.value == "profiles"
152
+ end&.last
153
+ unless profiles.is_a?(Psych::Nodes::Mapping) && profiles.children.any?
154
+ raise ConfigurationError, "Configuration profiles mapping cannot be updated safely"
155
+ end
156
+
157
+ profiles
158
+ end
159
+
160
+ def complete_final_line(lines, insertion_index)
161
+ return unless insertion_index == lines.length && lines.last && !lines.last.end_with?("\n")
162
+
163
+ lines[-1] = "#{lines.last}\n"
164
+ end
165
+
166
+ def replace_generator_placeholder(source, state)
167
+ placeholder = '"${GA4_PROPERTY_ID}"'
168
+ return nil unless source.scan(placeholder).length == 1
169
+
170
+ candidate = source.sub(placeholder, JSON.generate(state.property_id))
171
+ temporary_document(candidate, state)
172
+ candidate
173
+ end
174
+
175
+ def atomic_replace(path, original:, replacement:)
176
+ temporary_document(replacement)
177
+ temporary = temporary_path(path)
178
+ mode = File.stat(path).mode & 0o777
179
+ File.open(temporary, File::WRONLY | File::CREAT | File::EXCL, mode) do |file|
180
+ file.write(replacement)
181
+ file.flush
182
+ file.fsync
183
+ end
184
+ unless File.binread(path, Loader::MAX_BYTES + 1) == original
185
+ raise ConfigurationError, "Configuration #{Redaction.message(path)} changed while setup was running"
186
+ end
187
+
188
+ File.rename(temporary, path)
189
+ File.chmod(mode, path)
190
+ ensure
191
+ File.unlink(temporary) if temporary && File.exist?(temporary)
192
+ end
193
+
194
+ def temporary_document(source, expected_state = nil)
195
+ temporary = File.join(Dir.tmpdir, "analytics-ops-config-#{Process.pid}-#{SecureRandom.hex(6)}.yml")
196
+ File.open(temporary, File::WRONLY | File::CREAT | File::EXCL, 0o600) { |file| file.write(source) }
197
+ configuration = Configuration.load(temporary)
198
+ if expected_state
199
+ actual = configuration.profile(expected_state.profile)
200
+ unless actual.property_id == expected_state.property_id
201
+ raise ConfigurationError, "Generated configuration does not match the selected property"
202
+ end
203
+ end
204
+ configuration
205
+ ensure
206
+ File.unlink(temporary) if temporary && File.exist?(temporary)
207
+ end
103
208
  end
104
209
  end
105
210
  end
@@ -7,10 +7,12 @@ module AnalyticsOps
7
7
  class DesiredState
8
8
  attr_reader :profile, :property_id, :streams, :retention, :key_events,
9
9
  :custom_dimensions, :custom_metrics, :manual_requirements,
10
- :google_signals
10
+ :google_signals, :labels, :reports, :health, :funnels,
11
+ :bigquery, :governance
11
12
 
12
13
  def initialize(profile:, property_id:, streams:, retention:, key_events:, custom_dimensions:, custom_metrics:,
13
- manual_requirements:, google_signals:)
14
+ manual_requirements:, google_signals:, labels: {}, reports: {}, health: nil, funnels: {},
15
+ bigquery: nil, governance: nil)
14
16
  @profile = Canonical.immutable(profile)
15
17
  @property_id = Canonical.immutable(property_id)
16
18
  @streams = Canonical.immutable(streams)
@@ -20,6 +22,12 @@ module AnalyticsOps
20
22
  @custom_metrics = Canonical.immutable(custom_metrics)
21
23
  @manual_requirements = Canonical.immutable(manual_requirements)
22
24
  @google_signals = Canonical.immutable(google_signals)
25
+ @labels = Canonical.immutable(labels)
26
+ @reports = immutable_values(reports)
27
+ @health = Canonical.immutable(health)
28
+ @funnels = immutable_values(funnels)
29
+ @bigquery = Canonical.immutable(bigquery)
30
+ @governance = Canonical.immutable(governance)
23
31
  freeze
24
32
  end
25
33
 
@@ -33,8 +41,25 @@ module AnalyticsOps
33
41
  "custom_dimensions" => custom_dimensions,
34
42
  "custom_metrics" => custom_metrics,
35
43
  "manual_requirements" => manual_requirements,
36
- "google_signals" => google_signals
44
+ "google_signals" => google_signals,
45
+ "labels" => labels,
46
+ "reports" => reports.transform_values(&:to_h),
47
+ "health" => health,
48
+ "funnels" => funnels.transform_values { |value| value.respond_to?(:to_h) ? value.to_h : value },
49
+ "bigquery" => bigquery,
50
+ "governance" => governance
37
51
  }
38
52
  end
53
+
54
+ private
55
+
56
+ def immutable_values(value)
57
+ raise ArgumentError, "Expected a Hash" unless value.is_a?(Hash)
58
+
59
+ value.each_value do |child|
60
+ raise ArgumentError, "Expected immutable configured values" unless child.frozen?
61
+ end
62
+ value.dup.freeze
63
+ end
39
64
  end
40
65
  end
@@ -2,8 +2,40 @@
2
2
 
3
3
  # Public typed errors.
4
4
 
5
+ require_relative "redaction"
6
+
5
7
  module AnalyticsOps
6
- class Error < StandardError; end
8
+ # Base for every expected, safely reportable Analytics Ops failure.
9
+ class Error < StandardError
10
+ attr_reader :remote_reason, :remote_metadata, :remote_code
11
+
12
+ def initialize(message = nil, remote_reason: nil, remote_metadata: nil, remote_code: nil)
13
+ @remote_reason = safe_remote_value(remote_reason, 128)
14
+ @remote_metadata = safe_remote_metadata(remote_metadata)
15
+ @remote_code = safe_remote_value(remote_code, 64)
16
+ super(message)
17
+ end
18
+
19
+ private
20
+
21
+ def safe_remote_value(value, limit)
22
+ return nil if value.nil?
23
+
24
+ Redaction.message(value).slice(0, limit).freeze
25
+ end
26
+
27
+ def safe_remote_metadata(value)
28
+ return {}.freeze if value.nil?
29
+ return {}.freeze unless value.respond_to?(:to_h)
30
+
31
+ value.to_h.first(32).to_h do |key, child|
32
+ [safe_remote_value(key, 128), safe_remote_value(child, 256)]
33
+ end.freeze
34
+ rescue StandardError
35
+ {}.freeze
36
+ end
37
+ end
38
+
7
39
  class ConfigurationError < Error; end
8
40
  class EnvironmentVariableError < ConfigurationError; end
9
41
  class UnsupportedVersionError < ConfigurationError; end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AnalyticsOps
4
+ # User-defined, aggregate funnels executed through the optional BigQuery adapter.
5
+ module Funnels
6
+ # One named GA4 event in an ordered funnel.
7
+ class Step < Resources::Value
8
+ fields :name, :event_name
9
+ end
10
+
11
+ # Immutable definition for an aggregate, ordered BigQuery funnel.
12
+ class Definition
13
+ NAME = /\A[A-Za-z][A-Za-z0-9_]{0,63}\z/
14
+ EVENT_NAME = /\A[A-Za-z][A-Za-z0-9_]{0,39}\z/
15
+ CONTROL = /[\u0000-\u001f\u007f]/
16
+
17
+ attr_reader :name, :steps
18
+
19
+ def initialize(name:, steps:)
20
+ raise InvalidRequestError, "Funnel name is invalid" unless name.is_a?(String) && NAME.match?(name)
21
+ unless steps.is_a?(Array) && steps.length.between?(2, 10)
22
+ raise InvalidRequestError, "A funnel requires 2 to 10 steps"
23
+ end
24
+
25
+ @name = name.dup.freeze
26
+ @steps = steps.map.with_index { |step, index| normalize_step(step, index) }.freeze
27
+ unless @steps.map(&:name).uniq.length == @steps.length
28
+ raise InvalidRequestError, "Funnel step names must be unique"
29
+ end
30
+
31
+ freeze
32
+ end
33
+
34
+ def to_h
35
+ { "name" => name, "experimental" => true, "steps" => steps.map(&:to_h) }
36
+ end
37
+
38
+ private
39
+
40
+ def normalize_step(value, index)
41
+ unless value.is_a?(Hash) && value.keys.all?(String) && (value.keys - %w[name event_name]).empty?
42
+ raise InvalidRequestError, "Funnel step #{index + 1} is invalid"
43
+ end
44
+
45
+ name = value["name"]
46
+ event_name = value["event_name"]
47
+ unless name.is_a?(String) && !name.empty? && name.length <= 64 && !name.match?(CONTROL)
48
+ raise InvalidRequestError, "Funnel step #{index + 1} name is invalid"
49
+ end
50
+ unless event_name.is_a?(String) && EVENT_NAME.match?(event_name)
51
+ raise InvalidRequestError, "Funnel step #{index + 1} event_name is invalid"
52
+ end
53
+
54
+ Step.new(name:, event_name:)
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,363 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AnalyticsOps
4
+ # Read-only inspection of broader, mostly alpha Admin API capabilities.
5
+ module Governance
6
+ # Normalized read-only settings for one GA4 web stream.
7
+ class StreamSettings < Resources::Value
8
+ fields :stream_id, :stream_name, :enhanced_measurement, :data_redaction
9
+ end
10
+
11
+ # Immutable snapshot of experimental governance resources for one property.
12
+ class Snapshot
13
+ MAX_RESOURCES = 10_000
14
+
15
+ attr_reader :property_id, :stream_settings, :reporting_identity, :attribution,
16
+ :channel_groups, :calculated_metrics, :event_create_rules,
17
+ :event_edit_rules, :bigquery_links
18
+
19
+ def initialize(property_id:, stream_settings:, reporting_identity:, attribution:,
20
+ channel_groups:, calculated_metrics:, event_create_rules:,
21
+ event_edit_rules:, bigquery_links:)
22
+ validate_property_id!(property_id)
23
+ validate_streams!(property_id, stream_settings)
24
+ validate_resources!(
25
+ reporting_identity,
26
+ attribution,
27
+ channel_groups,
28
+ calculated_metrics,
29
+ event_create_rules,
30
+ event_edit_rules,
31
+ bigquery_links
32
+ )
33
+
34
+ @property_id = property_id.dup.freeze
35
+ @stream_settings = stream_settings.dup.freeze
36
+ @reporting_identity = Canonical.immutable(reporting_identity)
37
+ @attribution = Canonical.immutable(attribution)
38
+ @channel_groups = Canonical.immutable(channel_groups)
39
+ @calculated_metrics = Canonical.immutable(calculated_metrics)
40
+ @event_create_rules = Canonical.immutable(event_create_rules)
41
+ @event_edit_rules = Canonical.immutable(event_edit_rules)
42
+ @bigquery_links = Canonical.immutable(bigquery_links)
43
+ freeze
44
+ end
45
+
46
+ def to_h
47
+ {
48
+ "property_id" => property_id,
49
+ "stream_settings" => stream_settings.map(&:to_h),
50
+ "reporting_identity" => reporting_identity,
51
+ "attribution" => attribution,
52
+ "channel_groups" => channel_groups,
53
+ "calculated_metrics" => calculated_metrics,
54
+ "event_create_rules" => event_create_rules,
55
+ "event_edit_rules" => event_edit_rules,
56
+ "bigquery_links" => bigquery_links
57
+ }
58
+ end
59
+
60
+ private
61
+
62
+ def validate_property_id!(value)
63
+ return if value.is_a?(String) && value.match?(/\A\d{1,50}\z/)
64
+
65
+ raise RemoteError, "Governance property ID is invalid"
66
+ end
67
+
68
+ def validate_streams!(property_id, streams)
69
+ unless streams.is_a?(Array) && streams.all?(StreamSettings)
70
+ raise RemoteError, "Governance stream settings are invalid"
71
+ end
72
+
73
+ streams.each do |stream|
74
+ expected = "properties/#{property_id}/dataStreams/#{stream.stream_id}"
75
+ valid = stream.stream_id.is_a?(String) &&
76
+ stream.stream_id.match?(/\A\d{1,50}\z/) &&
77
+ stream.stream_name == expected &&
78
+ stream.enhanced_measurement.is_a?(Hash) &&
79
+ stream.data_redaction.is_a?(Hash)
80
+ raise RemoteError, "Governance stream belongs to a different property" unless valid
81
+ end
82
+ end
83
+
84
+ def validate_resources!(identity, attribution, *collections)
85
+ valid = identity.is_a?(Hash) &&
86
+ attribution.is_a?(Hash) &&
87
+ collections.all? do |collection|
88
+ collection.is_a?(Array) &&
89
+ collection.length <= MAX_RESOURCES &&
90
+ collection.all?(Hash)
91
+ end
92
+ raise RemoteError, "Governance snapshot collections are invalid" unless valid
93
+ end
94
+ end
95
+
96
+ # One deterministic governance mismatch.
97
+ class Finding < Resources::Value
98
+ fields :severity, :code, :resource, :message
99
+ end
100
+
101
+ # Governance snapshot paired with its configured findings.
102
+ class Result
103
+ attr_reader :snapshot, :findings
104
+
105
+ def initialize(snapshot:, findings:)
106
+ raise ArgumentError, "snapshot must be a governance snapshot" unless snapshot.is_a?(Snapshot)
107
+ unless findings.is_a?(Array) && findings.all?(Finding)
108
+ raise ArgumentError,
109
+ "findings must be governance findings"
110
+ end
111
+
112
+ @snapshot = snapshot
113
+ @findings = findings.dup.freeze
114
+ freeze
115
+ end
116
+
117
+ def compliant?
118
+ findings.empty?
119
+ end
120
+
121
+ def to_h
122
+ {
123
+ "experimental" => true,
124
+ "compliant" => compliant?,
125
+ "snapshot" => snapshot.to_h,
126
+ "findings" => findings.map(&:to_h)
127
+ }
128
+ end
129
+ end
130
+
131
+ # Bounded, deidentified Admin API change-history result.
132
+ class ChangeHistory
133
+ EVENT_KEYS = %w[actor_type change_time changes changes_filtered id].freeze
134
+ CHANGE_KEYS = %w[action resource].freeze
135
+ MAX_EVENTS = 200
136
+ MAX_CHANGES = 1_000
137
+
138
+ attr_reader :property_id, :events
139
+
140
+ def initialize(property_id:, events:)
141
+ unless property_id.is_a?(String) && property_id.match?(/\A\d{1,50}\z/) &&
142
+ events.is_a?(Array) && events.length <= MAX_EVENTS && events.all? { |event| valid_event?(event) }
143
+ raise RemoteError, "Change history is invalid"
144
+ end
145
+
146
+ @property_id = property_id.dup.freeze
147
+ @events = Canonical.immutable(events)
148
+ freeze
149
+ end
150
+
151
+ def to_h
152
+ { "experimental" => true, "property_id" => property_id, "events" => events }
153
+ end
154
+
155
+ private
156
+
157
+ def valid_event?(event)
158
+ event.is_a?(Hash) &&
159
+ event.keys.all?(String) &&
160
+ event.keys.sort == EVENT_KEYS &&
161
+ printable?(event["id"], 256) &&
162
+ printable?(event["change_time"], 128) &&
163
+ printable?(event["actor_type"], 128) &&
164
+ [true, false].include?(event["changes_filtered"]) &&
165
+ valid_changes?(event["changes"])
166
+ end
167
+
168
+ def valid_changes?(changes)
169
+ changes.is_a?(Array) &&
170
+ changes.length <= MAX_CHANGES &&
171
+ changes.all? do |change|
172
+ change.is_a?(Hash) &&
173
+ change.keys.all?(String) &&
174
+ change.keys.sort == CHANGE_KEYS &&
175
+ printable?(change["resource"], 1_024) &&
176
+ printable?(change["action"], 128)
177
+ end
178
+ end
179
+
180
+ def printable?(value, maximum)
181
+ value.is_a?(String) &&
182
+ value.length.between?(1, maximum) &&
183
+ value.valid_encoding? &&
184
+ !value.match?(/[\u0000-\u001f\u007f]/)
185
+ end
186
+ end
187
+
188
+ # Bounded aggregate report of Data API quota usage.
189
+ class AccessReport
190
+ attr_reader :property_id, :dimension_headers, :metric_headers, :rows, :row_count, :quota
191
+
192
+ def initialize(property_id:, dimension_headers:, metric_headers:, rows:, row_count:, quota:)
193
+ validate_property_id!(property_id)
194
+ headers = validate_headers!(dimension_headers, metric_headers)
195
+ validate_rows!(rows, headers)
196
+ validate_summary!(rows, row_count, quota)
197
+
198
+ @property_id = property_id.dup.freeze
199
+ @dimension_headers = Canonical.immutable(dimension_headers)
200
+ @metric_headers = Canonical.immutable(metric_headers)
201
+ @rows = Canonical.immutable(rows)
202
+ @row_count = row_count
203
+ @quota = Canonical.immutable(quota)
204
+ freeze
205
+ end
206
+
207
+ def to_h
208
+ {
209
+ "experimental" => true,
210
+ "property_id" => property_id,
211
+ "dimension_headers" => dimension_headers,
212
+ "metric_headers" => metric_headers,
213
+ "rows" => rows,
214
+ "row_count" => row_count,
215
+ "quota" => quota
216
+ }
217
+ end
218
+
219
+ private
220
+
221
+ def validate_property_id!(value)
222
+ return if value.is_a?(String) && value.match?(/\A\d{1,50}\z/)
223
+
224
+ raise RemoteError, "Access report is invalid"
225
+ end
226
+
227
+ def validate_headers!(dimensions, metrics)
228
+ headers = Array(dimensions) + Array(metrics)
229
+ valid = dimensions.is_a?(Array) &&
230
+ metrics.is_a?(Array) &&
231
+ headers.length.between?(1, 64) &&
232
+ headers.all? { |header| valid_header?(header) } &&
233
+ headers.uniq.length == headers.length
234
+ raise RemoteError, "Access report is invalid" unless valid
235
+
236
+ headers
237
+ end
238
+
239
+ def validate_rows!(rows, headers)
240
+ valid = rows.is_a?(Array) &&
241
+ rows.length <= 100_000 &&
242
+ rows.all? { |row| valid_row?(row, headers) }
243
+ raise RemoteError, "Access report is invalid" unless valid
244
+ end
245
+
246
+ def validate_summary!(rows, row_count, quota)
247
+ return if row_count.is_a?(Integer) && row_count >= rows.length && quota.is_a?(Hash)
248
+
249
+ raise RemoteError, "Access report is invalid"
250
+ end
251
+
252
+ def valid_header?(value)
253
+ value.is_a?(String) && value.match?(/\A[A-Za-z][A-Za-z0-9_]{0,127}\z/)
254
+ end
255
+
256
+ def valid_row?(row, headers)
257
+ row.is_a?(Hash) &&
258
+ row.keys.all?(String) &&
259
+ row.keys.sort == headers.sort &&
260
+ row.values.all? do |value|
261
+ value.is_a?(String) && value.valid_encoding? && value.bytesize <= 1_048_576
262
+ end
263
+ end
264
+ end
265
+
266
+ # Compares a governance snapshot with strict local expectations.
267
+ class Auditor
268
+ EXPECTED_COLLECTIONS = {
269
+ "expected_channel_groups" => %w[channel_groups display_name],
270
+ "expected_calculated_metrics" => %w[calculated_metrics display_name],
271
+ "expected_event_create_rules" => %w[event_create_rules destination_event],
272
+ "expected_event_edit_rules" => %w[event_edit_rules display_name]
273
+ }.freeze
274
+
275
+ def initialize(snapshot:, settings:)
276
+ @snapshot = snapshot
277
+ @settings = settings || {}
278
+ end
279
+
280
+ def call
281
+ findings = []
282
+ findings.concat(stream_findings)
283
+ findings.concat(identity_findings)
284
+ findings.concat(collection_findings)
285
+ Result.new(snapshot: @snapshot, findings:)
286
+ end
287
+
288
+ private
289
+
290
+ def stream_findings
291
+ if @snapshot.stream_settings.empty? && stream_requirements?
292
+ return [
293
+ finding(
294
+ "missing_web_stream",
295
+ "stream_settings",
296
+ "A web stream is required before stream-level governance settings can be checked"
297
+ )
298
+ ]
299
+ end
300
+
301
+ @snapshot.stream_settings.flat_map do |stream|
302
+ findings = []
303
+ if @settings["enhanced_measurement_required"] &&
304
+ !stream.enhanced_measurement.fetch("stream_enabled", false)
305
+ findings << finding("enhanced_measurement_disabled", stream.stream_name,
306
+ "Enhanced measurement is required but disabled")
307
+ end
308
+ if @settings["email_redaction_required"] &&
309
+ !stream.data_redaction.fetch("email_redaction_enabled", false)
310
+ findings << finding("email_redaction_disabled", stream.stream_name,
311
+ "Email redaction is required but disabled")
312
+ end
313
+ if @settings["query_parameter_redaction_required"] &&
314
+ !stream.data_redaction.fetch("query_parameter_redaction_enabled", false)
315
+ findings << finding("query_redaction_disabled", stream.stream_name,
316
+ "Query-parameter redaction is required but disabled")
317
+ end
318
+ findings
319
+ end
320
+ end
321
+
322
+ def stream_requirements?
323
+ %w[
324
+ enhanced_measurement_required email_redaction_required
325
+ query_parameter_redaction_required
326
+ ].any? { |name| @settings[name] }
327
+ end
328
+
329
+ def identity_findings
330
+ findings = []
331
+ expected = @settings["reporting_identity"]
332
+ actual = @snapshot.reporting_identity["reporting_identity"]
333
+ if expected && expected != actual
334
+ findings << finding(
335
+ "reporting_identity_mismatch",
336
+ "reporting_identity",
337
+ "Reporting identity is #{actual.inspect}; expected #{expected.inspect}"
338
+ )
339
+ end
340
+ if @settings["bigquery_link_required"] && @snapshot.bigquery_links.empty?
341
+ findings << finding("missing_bigquery_link", "bigquery_links", "At least one BigQuery link is required")
342
+ end
343
+ findings
344
+ end
345
+
346
+ def collection_findings
347
+ EXPECTED_COLLECTIONS.flat_map do |setting, (collection, identity)|
348
+ actual = @snapshot.public_send(collection).filter_map { |item| item[identity] }
349
+ Array(@settings[setting]).filter_map do |expected|
350
+ next if actual.include?(expected)
351
+
352
+ finding("missing_#{collection.delete_suffix("s")}", collection,
353
+ "Expected #{collection.tr("_", " ")} entry #{expected.inspect} was not found")
354
+ end
355
+ end
356
+ end
357
+
358
+ def finding(code, resource, message)
359
+ Finding.new(severity: "warning", code:, resource:, message:)
360
+ end
361
+ end
362
+ end
363
+ end