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
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AnalyticsOps
|
|
4
|
+
# Read-only summary across every profile in one Analytics Ops configuration.
|
|
5
|
+
class Portfolio
|
|
6
|
+
# One property/period row in a portfolio summary.
|
|
7
|
+
class Entry < Resources::Value
|
|
8
|
+
fields :profile, :property_id, :period, :active_users, :sessions, :key_events
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
# Immutable multi-property report result.
|
|
12
|
+
class Result
|
|
13
|
+
attr_reader :entries, :date_ranges
|
|
14
|
+
|
|
15
|
+
def initialize(entries:, date_ranges:)
|
|
16
|
+
unless entries.is_a?(Array) && entries.all?(Entry)
|
|
17
|
+
raise ArgumentError, "entries must contain AnalyticsOps::Portfolio::Entry values"
|
|
18
|
+
end
|
|
19
|
+
raise ArgumentError, "date_ranges must be an array" unless date_ranges.is_a?(Array)
|
|
20
|
+
|
|
21
|
+
@entries = entries.dup.freeze
|
|
22
|
+
@date_ranges = Canonical.immutable(date_ranges)
|
|
23
|
+
freeze
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def to_h
|
|
27
|
+
{
|
|
28
|
+
"entries" => entries.map(&:to_h),
|
|
29
|
+
"date_ranges" => date_ranges
|
|
30
|
+
}
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# One independent property result. Failures are isolated to that property.
|
|
35
|
+
class ProfileResult < Resources::Value
|
|
36
|
+
fields :profile, :property_id, :labels, :status, :result, :error
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Immutable generalized report or health collection across profiles.
|
|
40
|
+
class Collection
|
|
41
|
+
NAME = /\A[a-z][a-z0-9_-]{0,71}\z/
|
|
42
|
+
|
|
43
|
+
attr_reader :kind, :name, :entries, :date_ranges
|
|
44
|
+
|
|
45
|
+
def initialize(kind:, name:, entries:, date_ranges:)
|
|
46
|
+
unless %w[report health].include?(kind) && name.is_a?(String) && NAME.match?(name) &&
|
|
47
|
+
entries.is_a?(Array) && entries.all?(ProfileResult)
|
|
48
|
+
raise ArgumentError, "Invalid portfolio collection"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
@kind = kind.dup.freeze
|
|
52
|
+
@name = name.dup.freeze
|
|
53
|
+
@entries = entries.dup.freeze
|
|
54
|
+
@date_ranges = Canonical.immutable(date_ranges)
|
|
55
|
+
freeze
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def success?
|
|
59
|
+
entries.all? { |entry| entry.status == "ok" }
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def to_h
|
|
63
|
+
{
|
|
64
|
+
"kind" => kind,
|
|
65
|
+
"name" => name,
|
|
66
|
+
"success" => success?,
|
|
67
|
+
"date_ranges" => date_ranges,
|
|
68
|
+
"entries" => entries.map(&:to_h)
|
|
69
|
+
}
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def initialize(config:, store: ServiceAccount::Store.new, workspace_loader: nil,
|
|
74
|
+
service_account_loader: nil, transport: :grpc, timeout: nil, logger: nil)
|
|
75
|
+
@config = config
|
|
76
|
+
@store = store
|
|
77
|
+
@workspace_loader = workspace_loader || method(:load_workspace)
|
|
78
|
+
@service_account_loader = service_account_loader || ServiceAccount.method(:load)
|
|
79
|
+
@transport = transport
|
|
80
|
+
@timeout = timeout
|
|
81
|
+
@logger = logger
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def overview(date_ranges: nil)
|
|
85
|
+
document = Configuration.load(@config)
|
|
86
|
+
definition = Reports::Catalog.overview.first
|
|
87
|
+
definition = definition.with_date_ranges(date_ranges) if date_ranges
|
|
88
|
+
entries = document.profiles.sort.flat_map do |profile, desired_state|
|
|
89
|
+
report = workspace(profile).report(definition)
|
|
90
|
+
report_entries(profile, desired_state.property_id, report)
|
|
91
|
+
end
|
|
92
|
+
Result.new(entries:, date_ranges: definition.date_ranges)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def report(name, date_ranges: nil, concurrency: 4)
|
|
96
|
+
collect(kind: "report", name: name.to_s, date_ranges:, concurrency:) do |target|
|
|
97
|
+
result = date_ranges ? target.report(name, date_ranges:) : target.report(name)
|
|
98
|
+
result.to_h
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def health(date_ranges: nil, concurrency: 4)
|
|
103
|
+
collect(kind: "health", name: "health", date_ranges:, concurrency:) do |target|
|
|
104
|
+
result = date_ranges ? target.health(date_ranges:) : target.health
|
|
105
|
+
result.to_h
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
private
|
|
110
|
+
|
|
111
|
+
def collect(kind:, name:, date_ranges:, concurrency:)
|
|
112
|
+
unless name.is_a?(String) && Collection::NAME.match?(name)
|
|
113
|
+
raise InvalidRequestError, "Portfolio report name is invalid"
|
|
114
|
+
end
|
|
115
|
+
unless concurrency.is_a?(Integer) && concurrency.between?(1, 8)
|
|
116
|
+
raise InvalidRequestError, "Portfolio concurrency must be between 1 and 8"
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
profiles = Configuration.load(@config).profiles.sort
|
|
120
|
+
queue = Queue.new
|
|
121
|
+
profiles.each_with_index { |(profile, desired_state), index| queue << [index, profile, desired_state] }
|
|
122
|
+
entries = Array.new(profiles.length)
|
|
123
|
+
workers = [concurrency, profiles.length].min.times.map do
|
|
124
|
+
Thread.new do
|
|
125
|
+
loop do
|
|
126
|
+
index, profile, desired_state = queue.pop(true)
|
|
127
|
+
entries[index] = collect_profile(profile, desired_state) { yield workspace(profile) }
|
|
128
|
+
rescue ThreadError
|
|
129
|
+
break
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
workers.each(&:join)
|
|
134
|
+
Collection.new(kind:, name:, entries:, date_ranges: date_ranges || [])
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def collect_profile(profile, desired_state)
|
|
138
|
+
payload = yield
|
|
139
|
+
ProfileResult.new(
|
|
140
|
+
profile:,
|
|
141
|
+
property_id: desired_state.property_id,
|
|
142
|
+
labels: desired_state.labels,
|
|
143
|
+
status: "ok",
|
|
144
|
+
result: payload,
|
|
145
|
+
error: nil
|
|
146
|
+
)
|
|
147
|
+
rescue AnalyticsOps::Error => error
|
|
148
|
+
ProfileResult.new(
|
|
149
|
+
profile:,
|
|
150
|
+
property_id: desired_state.property_id,
|
|
151
|
+
labels: desired_state.labels,
|
|
152
|
+
status: "error",
|
|
153
|
+
result: nil,
|
|
154
|
+
error: {
|
|
155
|
+
"type" => error.class.name.split("::").last,
|
|
156
|
+
"message" => Redaction.message(error.message)
|
|
157
|
+
}
|
|
158
|
+
)
|
|
159
|
+
rescue StandardError => error
|
|
160
|
+
SafeLogging.write(@logger, :error, "portfolio_profile_error", "type" => error.class.name, "profile" => profile)
|
|
161
|
+
ProfileResult.new(
|
|
162
|
+
profile:,
|
|
163
|
+
property_id: desired_state.property_id,
|
|
164
|
+
labels: desired_state.labels,
|
|
165
|
+
status: "error",
|
|
166
|
+
result: nil,
|
|
167
|
+
error: {
|
|
168
|
+
"type" => "InternalError",
|
|
169
|
+
"message" => "Analytics Ops could not complete this read-only profile request"
|
|
170
|
+
}
|
|
171
|
+
)
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def workspace(profile)
|
|
175
|
+
service_account = @service_account_loader.call(
|
|
176
|
+
store: @store,
|
|
177
|
+
connection: nil,
|
|
178
|
+
config: @config,
|
|
179
|
+
profile:
|
|
180
|
+
)
|
|
181
|
+
@workspace_loader.call(
|
|
182
|
+
config: @config,
|
|
183
|
+
profile:,
|
|
184
|
+
service_account:,
|
|
185
|
+
transport: @transport,
|
|
186
|
+
timeout: @timeout,
|
|
187
|
+
logger: @logger
|
|
188
|
+
)
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def load_workspace(config:, profile:, service_account:, transport:, timeout:, logger:)
|
|
192
|
+
Workspace.load(config, profile:, service_account:, transport:, timeout:, logger:)
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def report_entries(profile, property_id, report)
|
|
196
|
+
unless report.is_a?(Reports::Result) &&
|
|
197
|
+
report.metric_headers == %w[activeUsers sessions keyEvents] &&
|
|
198
|
+
[[], ["dateRange"]].include?(report.dimension_headers)
|
|
199
|
+
raise RemoteError, "Portfolio report returned an unexpected shape"
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
rows = report.rows
|
|
203
|
+
rows = [{ "activeUsers" => "0", "sessions" => "0", "keyEvents" => "0" }] if rows.empty?
|
|
204
|
+
rows.map do |row|
|
|
205
|
+
Entry.new(
|
|
206
|
+
profile:,
|
|
207
|
+
property_id:,
|
|
208
|
+
period: row.fetch("dateRange", "current"),
|
|
209
|
+
active_users: row.fetch("activeUsers"),
|
|
210
|
+
sessions: row.fetch("sessions"),
|
|
211
|
+
key_events: row.fetch("keyEvents")
|
|
212
|
+
)
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
end
|
|
@@ -8,9 +8,9 @@ module AnalyticsOps
|
|
|
8
8
|
def run(command, report_name: nil)
|
|
9
9
|
arguments = [
|
|
10
10
|
command,
|
|
11
|
-
"--config", ENV.fetch("ANALYTICS_OPS_CONFIG", Rails.root.join("config/analytics_ops.yml").to_s)
|
|
12
|
-
"--profile", ENV.fetch("ANALYTICS_OPS_PROFILE", Rails.env.to_s)
|
|
11
|
+
"--config", ENV.fetch("ANALYTICS_OPS_CONFIG", Rails.root.join("config/analytics_ops.yml").to_s)
|
|
13
12
|
]
|
|
13
|
+
arguments.push("--profile", ENV.fetch("ANALYTICS_OPS_PROFILE")) if ENV.key?("ANALYTICS_OPS_PROFILE")
|
|
14
14
|
if command == "plan"
|
|
15
15
|
arguments.push("--output",
|
|
16
16
|
ENV.fetch("ANALYTICS_OPS_PLAN", Rails.root.join("tmp/analytics_ops-plan.json").to_s))
|
|
@@ -31,7 +31,16 @@ module AnalyticsOps
|
|
|
31
31
|
audit: "Audit configured Google Analytics state without changing it",
|
|
32
32
|
plan: "Write a deterministic Analytics Ops plan",
|
|
33
33
|
verify: "Verify that managed Google Analytics state converges",
|
|
34
|
-
overview: "Show the selected property's Analytics overview"
|
|
34
|
+
overview: "Show the selected property's Analytics overview",
|
|
35
|
+
portfolio: "Show totals across every configured Analytics property",
|
|
36
|
+
health: "Check traffic, data quality, quota, and configuration drift",
|
|
37
|
+
governance: "Audit broader GA4 governance settings without changing them",
|
|
38
|
+
access: "Read the aggregate GA4 access report"
|
|
39
|
+
}.freeze
|
|
40
|
+
NAMED_TASKS = {
|
|
41
|
+
report: "Run a built-in report",
|
|
42
|
+
raw: "Run an optional BigQuery recipe",
|
|
43
|
+
funnel: "Run a configured funnel"
|
|
35
44
|
}.freeze
|
|
36
45
|
|
|
37
46
|
generators do
|
|
@@ -47,12 +56,14 @@ module AnalyticsOps
|
|
|
47
56
|
end
|
|
48
57
|
end
|
|
49
58
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
59
|
+
NAMED_TASKS.each do |command, description|
|
|
60
|
+
desc "#{description}: rake analytics:#{command}[NAME]"
|
|
61
|
+
task command, [:name] => :environment do |_task, arguments|
|
|
62
|
+
name = arguments[:name]
|
|
63
|
+
raise Error, "analytics:#{command} requires NAME" if name.to_s.empty?
|
|
54
64
|
|
|
55
|
-
|
|
65
|
+
RailsTasks.run(command.to_s, report_name: name)
|
|
66
|
+
end
|
|
56
67
|
end
|
|
57
68
|
end
|
|
58
69
|
end
|
|
@@ -25,13 +25,17 @@ module AnalyticsOps
|
|
|
25
25
|
end
|
|
26
26
|
|
|
27
27
|
def message(value)
|
|
28
|
+
text(value, limit: 1_000)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def text(value, limit: nil)
|
|
28
32
|
text = value.to_s.encode(Encoding::UTF_8, invalid: :replace, undef: :replace, replace: "?")
|
|
29
|
-
text.gsub(PRIVATE_KEY, "[REDACTED]")
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
33
|
+
redacted = text.gsub(PRIVATE_KEY, "[REDACTED]")
|
|
34
|
+
.gsub(AUTHORIZATION, "Authorization: [REDACTED]")
|
|
35
|
+
.gsub(BEARER, "Bearer [REDACTED]")
|
|
36
|
+
.gsub(SECRET_ASSIGNMENT) { "#{Regexp.last_match(1)}=[REDACTED]" }
|
|
37
|
+
.gsub(CONTROL_CHARACTERS, "?")
|
|
38
|
+
limit ? redacted.slice(0, limit) : redacted
|
|
35
39
|
end
|
|
36
40
|
end
|
|
37
41
|
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "csv"
|
|
4
|
+
require "securerandom"
|
|
5
|
+
|
|
6
|
+
module AnalyticsOps
|
|
7
|
+
module Reports
|
|
8
|
+
# Immutable receipt for one bounded CSV export.
|
|
9
|
+
class ExportResult < Resources::Value
|
|
10
|
+
fields :path, :rows, :headers
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# Atomic, spreadsheet-safe streaming export for report pages.
|
|
14
|
+
module CSVExport
|
|
15
|
+
module_function
|
|
16
|
+
|
|
17
|
+
def write(path, pages)
|
|
18
|
+
destination = validated_path(path)
|
|
19
|
+
temporary = File.join(
|
|
20
|
+
File.dirname(destination),
|
|
21
|
+
".#{File.basename(destination)}.#{Process.pid}.#{SecureRandom.hex(6)}.tmp"
|
|
22
|
+
)
|
|
23
|
+
headers = nil
|
|
24
|
+
row_count = nil
|
|
25
|
+
File.open(temporary, File::WRONLY | File::CREAT | File::EXCL, 0o600) do |file|
|
|
26
|
+
headers, row_count = write_pages(file, pages)
|
|
27
|
+
file.flush
|
|
28
|
+
file.fsync
|
|
29
|
+
end
|
|
30
|
+
File.rename(temporary, destination)
|
|
31
|
+
File.chmod(0o600, destination)
|
|
32
|
+
ExportResult.new(path: destination, rows: row_count, headers:)
|
|
33
|
+
rescue AnalyticsOps::Error
|
|
34
|
+
raise
|
|
35
|
+
rescue SystemCallError => error
|
|
36
|
+
raise ConfigurationError, "Cannot write CSV export: #{Redaction.message(error.message)}"
|
|
37
|
+
ensure
|
|
38
|
+
File.unlink(temporary) if temporary && File.exist?(temporary)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def write_pages(file, pages)
|
|
42
|
+
headers = nil
|
|
43
|
+
row_count = 0
|
|
44
|
+
pages.each do |page|
|
|
45
|
+
headers, written = write_page(file, page, headers)
|
|
46
|
+
row_count += written
|
|
47
|
+
end
|
|
48
|
+
raise RemoteError, "CSV export did not receive a report page" unless headers
|
|
49
|
+
|
|
50
|
+
[headers, row_count]
|
|
51
|
+
end
|
|
52
|
+
private_class_method :write_pages
|
|
53
|
+
|
|
54
|
+
def write_page(file, page, headers)
|
|
55
|
+
raise InvalidRequestError, "CSV export pages must be report results" unless page.is_a?(Result)
|
|
56
|
+
|
|
57
|
+
if headers
|
|
58
|
+
raise RemoteError, "Paginated report headers changed during export" unless page.headers == headers
|
|
59
|
+
else
|
|
60
|
+
headers = page.headers
|
|
61
|
+
file.write(CSV.generate_line(headers))
|
|
62
|
+
end
|
|
63
|
+
page.rows.each do |row|
|
|
64
|
+
file.write(CSV.generate_line(headers.map { |header| safe_cell(row.fetch(header)) }))
|
|
65
|
+
end
|
|
66
|
+
[headers, page.rows.length]
|
|
67
|
+
end
|
|
68
|
+
private_class_method :write_page
|
|
69
|
+
|
|
70
|
+
def safe_cell(value)
|
|
71
|
+
string = value.to_s.gsub(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/, "?")
|
|
72
|
+
string.match?(/\A(?:[\p{Space}\uFEFF]*[=+\-@]|[\t\r\n])/) ? "'#{string}" : string
|
|
73
|
+
end
|
|
74
|
+
private_class_method :safe_cell
|
|
75
|
+
|
|
76
|
+
def validated_path(value)
|
|
77
|
+
unless value.is_a?(String) && !value.empty? && value.length <= 4_096 &&
|
|
78
|
+
!value.match?(/[\u0000-\u001f\u007f]/)
|
|
79
|
+
raise ConfigurationError, "CSV output path is invalid"
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
expanded = File.expand_path(value)
|
|
83
|
+
directory = File.dirname(expanded)
|
|
84
|
+
raise ConfigurationError, "CSV output directory does not exist" unless File.directory?(directory)
|
|
85
|
+
|
|
86
|
+
expanded
|
|
87
|
+
end
|
|
88
|
+
private_class_method :validated_path
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
@@ -14,7 +14,12 @@ module AnalyticsOps
|
|
|
14
14
|
RELATIVE_DATE = /\A(\d{1,4})daysAgo\z/
|
|
15
15
|
MATCH_TYPES = %w[exact begins_with ends_with contains full_regexp partial_regexp].freeze
|
|
16
16
|
NUMERIC_OPERATIONS = %w[equal less_than less_than_or_equal greater_than greater_than_or_equal].freeze
|
|
17
|
-
|
|
17
|
+
GROUP_FILTERS = %w[and or not].freeze
|
|
18
|
+
MAX_FILTER_DEPTH = 8
|
|
19
|
+
MAX_FILTER_CHILDREN = 20
|
|
20
|
+
MAX_STANDARD_LIMIT = 250_000
|
|
21
|
+
MAX_REALTIME_LIMIT = 100_000
|
|
22
|
+
MAX_LIMIT = MAX_STANDARD_LIMIT
|
|
18
23
|
INT64_RANGE = (-(2**63))..((2**63) - 1)
|
|
19
24
|
|
|
20
25
|
attr_reader :name, :kind, :dimensions, :metrics, :date_ranges,
|
|
@@ -27,11 +32,12 @@ module AnalyticsOps
|
|
|
27
32
|
@dimensions = api_names(dimensions, "dimensions", minimum: 0, maximum: realtime_kind?(kind) ? 4 : 9)
|
|
28
33
|
@metrics = api_names(metrics, "metrics", minimum: 1, maximum: 10)
|
|
29
34
|
@date_ranges = ranges(date_ranges)
|
|
30
|
-
@dimension_filter =
|
|
31
|
-
@metric_filter =
|
|
35
|
+
@dimension_filter = filter_expression(dimension_filter, type: :dimension)
|
|
36
|
+
@metric_filter = filter_expression(metric_filter, type: :metric)
|
|
32
37
|
@order_bys = orders(order_bys)
|
|
33
38
|
@offset = integer(offset, "offset", minimum: 0, maximum: 1_000_000_000)
|
|
34
|
-
|
|
39
|
+
maximum_limit = realtime? ? MAX_REALTIME_LIMIT : MAX_STANDARD_LIMIT
|
|
40
|
+
@limit = integer(limit, "limit", minimum: 1, maximum: maximum_limit)
|
|
35
41
|
validate_shape!
|
|
36
42
|
freeze
|
|
37
43
|
end
|
|
@@ -40,6 +46,36 @@ module AnalyticsOps
|
|
|
40
46
|
kind == "realtime"
|
|
41
47
|
end
|
|
42
48
|
|
|
49
|
+
def with_date_ranges(value)
|
|
50
|
+
self.class.new(
|
|
51
|
+
name:,
|
|
52
|
+
kind:,
|
|
53
|
+
dimensions:,
|
|
54
|
+
metrics:,
|
|
55
|
+
date_ranges: value,
|
|
56
|
+
dimension_filter:,
|
|
57
|
+
metric_filter:,
|
|
58
|
+
order_bys:,
|
|
59
|
+
offset:,
|
|
60
|
+
limit:
|
|
61
|
+
)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def with_pagination(offset:, limit:)
|
|
65
|
+
self.class.new(
|
|
66
|
+
name:,
|
|
67
|
+
kind:,
|
|
68
|
+
dimensions:,
|
|
69
|
+
metrics:,
|
|
70
|
+
date_ranges:,
|
|
71
|
+
dimension_filter:,
|
|
72
|
+
metric_filter:,
|
|
73
|
+
order_bys:,
|
|
74
|
+
offset:,
|
|
75
|
+
limit:
|
|
76
|
+
)
|
|
77
|
+
end
|
|
78
|
+
|
|
43
79
|
def to_h
|
|
44
80
|
{
|
|
45
81
|
"name" => name,
|
|
@@ -152,16 +188,73 @@ module AnalyticsOps
|
|
|
152
188
|
RELATIVE_DATE.match(value)&.[](1)&.to_i
|
|
153
189
|
end
|
|
154
190
|
|
|
155
|
-
def
|
|
191
|
+
def filter_expression(value, type:, depth: 0)
|
|
156
192
|
return nil if value.nil?
|
|
157
193
|
|
|
158
|
-
|
|
194
|
+
invalid!("Report filter nesting is too deep") if depth > MAX_FILTER_DEPTH
|
|
195
|
+
|
|
196
|
+
hash = string_hash(value, "#{type} filter", filter_allowed_keys(type))
|
|
197
|
+
groups = GROUP_FILTERS.select { |key| hash.key?(key) }
|
|
198
|
+
return filter_group(hash, type:, depth:, groups:) unless groups.empty?
|
|
199
|
+
|
|
200
|
+
type == :dimension ? string_filter(hash) : numeric_filter(hash)
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def filter_allowed_keys(type)
|
|
204
|
+
primitive = if type == :dimension
|
|
205
|
+
%w[field match_type value case_sensitive in_list null]
|
|
206
|
+
else
|
|
207
|
+
%w[field operation value between null]
|
|
208
|
+
end
|
|
209
|
+
GROUP_FILTERS + primitive
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def filter_group(hash, type:, depth:, groups:)
|
|
213
|
+
invalid!("A filter expression requires exactly one of and, or, or not") unless groups.length == 1
|
|
214
|
+
invalid!("A filter group cannot include primitive filter fields") unless hash.keys == groups
|
|
215
|
+
|
|
216
|
+
operator = groups.first
|
|
217
|
+
child = hash.fetch(operator)
|
|
218
|
+
if operator == "not"
|
|
219
|
+
invalid!("not must contain one filter object") unless child.is_a?(Hash)
|
|
220
|
+
|
|
221
|
+
return Canonical.immutable("not" => filter_expression(child, type:, depth: depth + 1))
|
|
222
|
+
end
|
|
223
|
+
unless child.is_a?(Array) && child.length.between?(2, MAX_FILTER_CHILDREN)
|
|
224
|
+
invalid!("#{operator} must contain 2 to #{MAX_FILTER_CHILDREN} filter objects")
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
Canonical.immutable(
|
|
228
|
+
operator => child.map { |item| filter_expression(item, type:, depth: depth + 1) }
|
|
229
|
+
)
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def string_filter(hash)
|
|
159
233
|
field = hash.fetch("field") { invalid!("Missing filter field") }
|
|
234
|
+
unless valid_api_name?(field) && dimensions.include?(field)
|
|
235
|
+
invalid!("Dimension filter is invalid or references an unselected dimension")
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
shapes = [
|
|
239
|
+
hash.key?("match_type") || hash.key?("value"),
|
|
240
|
+
hash.key?("in_list"),
|
|
241
|
+
hash.key?("null")
|
|
242
|
+
]
|
|
243
|
+
unless shapes.count(true) == 1
|
|
244
|
+
invalid!("Dimension filter requires exactly one string, in_list, or null condition")
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
return string_match_filter(hash, field) if shapes[0]
|
|
248
|
+
return in_list_filter(hash, field) if shapes[1]
|
|
249
|
+
|
|
250
|
+
null_filter(hash, field, type: :dimension)
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
def string_match_filter(hash, field)
|
|
160
254
|
match_type = hash.fetch("match_type") { invalid!("Missing filter match_type") }
|
|
161
255
|
filter_value = hash.fetch("value") { invalid!("Missing filter value") }
|
|
162
|
-
unless
|
|
163
|
-
|
|
164
|
-
invalid!("Dimension filter is invalid or references an unselected dimension")
|
|
256
|
+
unless MATCH_TYPES.include?(match_type) && printable_string?(filter_value, maximum: 1_024)
|
|
257
|
+
invalid!("Dimension string filter is invalid")
|
|
165
258
|
end
|
|
166
259
|
|
|
167
260
|
case_sensitive = hash.key?("case_sensitive") ? boolean(hash.fetch("case_sensitive"), "case_sensitive") : false
|
|
@@ -173,15 +266,42 @@ module AnalyticsOps
|
|
|
173
266
|
)
|
|
174
267
|
end
|
|
175
268
|
|
|
176
|
-
def
|
|
177
|
-
|
|
269
|
+
def in_list_filter(hash, field)
|
|
270
|
+
values = hash.fetch("in_list")
|
|
271
|
+
unless values.is_a?(Array) && values.length.between?(1, 100) &&
|
|
272
|
+
values.all? { |value| printable_string?(value, maximum: 1_024) } &&
|
|
273
|
+
values.uniq.length == values.length
|
|
274
|
+
invalid!("Dimension in_list must contain 1 to 100 unique printable strings")
|
|
275
|
+
end
|
|
276
|
+
case_sensitive = hash.key?("case_sensitive") ? boolean(hash.fetch("case_sensitive"), "case_sensitive") : false
|
|
277
|
+
Canonical.immutable("field" => field, "in_list" => values, "case_sensitive" => case_sensitive)
|
|
278
|
+
end
|
|
178
279
|
|
|
179
|
-
|
|
280
|
+
def numeric_filter(hash)
|
|
180
281
|
field = hash.fetch("field") { invalid!("Missing metric filter field") }
|
|
282
|
+
unless valid_api_name?(field) && metrics.include?(field)
|
|
283
|
+
invalid!("Metric filter is invalid or references an unselected metric")
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
shapes = [
|
|
287
|
+
hash.key?("operation") || hash.key?("value"),
|
|
288
|
+
hash.key?("between"),
|
|
289
|
+
hash.key?("null")
|
|
290
|
+
]
|
|
291
|
+
unless shapes.count(true) == 1
|
|
292
|
+
invalid!("Metric filter requires exactly one numeric, between, or null condition")
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
return numeric_comparison_filter(hash, field) if shapes[0]
|
|
296
|
+
return between_filter(hash, field) if shapes[1]
|
|
297
|
+
|
|
298
|
+
null_filter(hash, field, type: :metric)
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
def numeric_comparison_filter(hash, field)
|
|
181
302
|
operation = hash.fetch("operation") { invalid!("Missing metric filter operation") }
|
|
182
303
|
number = hash.fetch("value") { invalid!("Missing metric filter value") }
|
|
183
|
-
|
|
184
|
-
invalid!("Metric filter is invalid or references an unselected metric") unless valid
|
|
304
|
+
invalid!("Metric filter operation is invalid") unless NUMERIC_OPERATIONS.include?(operation)
|
|
185
305
|
unless valid_numeric_filter_value?(number)
|
|
186
306
|
invalid!("Metric filter value must be a finite int64 Integer or Float")
|
|
187
307
|
end
|
|
@@ -189,6 +309,24 @@ module AnalyticsOps
|
|
|
189
309
|
Canonical.immutable("field" => field, "operation" => operation, "value" => number)
|
|
190
310
|
end
|
|
191
311
|
|
|
312
|
+
def between_filter(hash, field)
|
|
313
|
+
bounds = hash.fetch("between")
|
|
314
|
+
unless bounds.is_a?(Array) && bounds.length == 2 && bounds.all? { |value| valid_numeric_filter_value?(value) }
|
|
315
|
+
invalid!("Metric between filter must contain two finite numeric values")
|
|
316
|
+
end
|
|
317
|
+
invalid!("Metric between lower bound must not exceed its upper bound") if bounds[0] > bounds[1]
|
|
318
|
+
|
|
319
|
+
Canonical.immutable("field" => field, "between" => bounds)
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
def null_filter(hash, field, type:)
|
|
323
|
+
invalid!("#{type} null filter must be true") unless hash.fetch("null") == true
|
|
324
|
+
allowed = %w[field null]
|
|
325
|
+
invalid!("#{type} null filter contains an unsupported option") unless (hash.keys - allowed).empty?
|
|
326
|
+
|
|
327
|
+
Canonical.immutable("field" => field, "null" => true)
|
|
328
|
+
end
|
|
329
|
+
|
|
192
330
|
def valid_numeric_filter_value?(number)
|
|
193
331
|
(number.is_a?(Integer) && INT64_RANGE.cover?(number)) || (number.is_a?(Float) && number.finite?)
|
|
194
332
|
end
|