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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +108 -0
- data/CONTRIBUTING.md +1 -0
- data/README.md +141 -13
- data/SECURITY.md +11 -2
- data/docs/ai-connections.md +192 -0
- data/docs/api-support-matrix.md +25 -8
- data/docs/architecture.md +78 -17
- data/docs/authentication.md +53 -3
- data/docs/bigquery.md +128 -0
- data/docs/commands.md +133 -12
- data/docs/configuration-schema-v1.json +608 -50
- data/docs/configuration.md +118 -5
- data/docs/google-client-compatibility.md +21 -1
- data/docs/governance.md +93 -0
- data/docs/health.md +82 -0
- data/docs/live-smoke-test.md +13 -10
- data/docs/rails.md +25 -9
- data/docs/reports.md +165 -3
- data/docs/safety.md +37 -5
- data/docs/troubleshooting.md +81 -7
- data/lib/analytics_ops/bigquery/definition.rb +173 -0
- data/lib/analytics_ops/bigquery.rb +4 -0
- data/lib/analytics_ops/cli/local_commands.rb +80 -0
- data/lib/analytics_ops/cli/options.rb +282 -9
- data/lib/analytics_ops/cli/presenter.rb +263 -28
- data/lib/analytics_ops/cli/presenter_csv.rb +92 -0
- data/lib/analytics_ops/cli/reporting_commands.rb +129 -0
- data/lib/analytics_ops/cli/setup_commands.rb +91 -0
- data/lib/analytics_ops/cli.rb +121 -44
- data/lib/analytics_ops/clients/admin.rb +28 -2
- data/lib/analytics_ops/clients/admin_governance.rb +95 -0
- data/lib/analytics_ops/clients/admin_governance_normalization.rb +90 -0
- data/lib/analytics_ops/clients/admin_resource_normalization.rb +61 -0
- data/lib/analytics_ops/clients/bigquery.rb +313 -0
- data/lib/analytics_ops/clients/data.rb +147 -54
- data/lib/analytics_ops/clients/data_result_normalization.rb +55 -0
- data/lib/analytics_ops/clients/error_translation.rb +13 -1
- data/lib/analytics_ops/configuration/schema.rb +175 -1
- data/lib/analytics_ops/configuration/validator.rb +45 -7
- data/lib/analytics_ops/configuration/validator_experimental.rb +136 -0
- data/lib/analytics_ops/configuration/validator_reporting.rb +93 -0
- data/lib/analytics_ops/configuration/writer.rb +112 -7
- data/lib/analytics_ops/desired_state.rb +28 -3
- data/lib/analytics_ops/errors.rb +33 -1
- data/lib/analytics_ops/funnels.rb +58 -0
- data/lib/analytics_ops/governance.rb +363 -0
- data/lib/analytics_ops/health.rb +310 -0
- data/lib/analytics_ops/mcp_server/custom_report_tool.rb +101 -0
- data/lib/analytics_ops/mcp_server/discovery_tools.rb +79 -0
- data/lib/analytics_ops/mcp_server/experimental_tools.rb +89 -0
- data/lib/analytics_ops/mcp_server/health_tools.rb +80 -0
- data/lib/analytics_ops/mcp_server/overview_tools.rb +49 -0
- data/lib/analytics_ops/mcp_server/standard_report_tool.rb +53 -0
- data/lib/analytics_ops/mcp_server.rb +402 -0
- data/lib/analytics_ops/portfolio.rb +216 -0
- data/lib/analytics_ops/rails/railtie.rb +19 -8
- data/lib/analytics_ops/redaction.rb +10 -6
- data/lib/analytics_ops/reports/csv_export.rb +91 -0
- data/lib/analytics_ops/reports/definition.rb +152 -14
- data/lib/analytics_ops/reports/metadata.rb +158 -0
- data/lib/analytics_ops/reports/period.rb +104 -0
- data/lib/analytics_ops/reports/result.rb +8 -3
- data/lib/analytics_ops/reports.rb +3 -0
- data/lib/analytics_ops/service_account.rb +321 -29
- data/lib/analytics_ops/setup.rb +31 -7
- data/lib/analytics_ops/version.rb +1 -1
- data/lib/analytics_ops/workspace.rb +222 -10
- data/lib/analytics_ops.rb +7 -2
- data/lib/generators/analytics_ops/templates/analytics_ops.yml +1 -1
- data/sig/analytics_ops.rbs +494 -9
- 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
|
-
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
data/lib/analytics_ops/errors.rb
CHANGED
|
@@ -2,8 +2,40 @@
|
|
|
2
2
|
|
|
3
3
|
# Public typed errors.
|
|
4
4
|
|
|
5
|
+
require_relative "redaction"
|
|
6
|
+
|
|
5
7
|
module AnalyticsOps
|
|
6
|
-
|
|
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
|