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
@@ -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
- desc "Run a built-in report: rake analytics:report[NAME]"
51
- task :report, [:name] => :environment do |_task, arguments|
52
- name = arguments[:name]
53
- raise Error, "analytics:report requires NAME" if name.to_s.empty?
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
- RailsTasks.run("report", report_name: name)
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
- .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)
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
- MAX_LIMIT = 100_000
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 = string_filter(dimension_filter)
31
- @metric_filter = numeric_filter(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
- @limit = integer(limit, "limit", minimum: 1, maximum: MAX_LIMIT)
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 string_filter(value)
191
+ def filter_expression(value, type:, depth: 0)
156
192
  return nil if value.nil?
157
193
 
158
- hash = string_hash(value, "dimension filter", %w[field match_type value case_sensitive])
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 valid_api_name?(field) && dimensions.include?(field) && MATCH_TYPES.include?(match_type) &&
163
- printable_string?(filter_value, maximum: 1_024)
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 numeric_filter(value)
177
- return nil if value.nil?
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
- hash = string_hash(value, "metric filter", %w[field operation value])
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
- valid = valid_api_name?(field) && metrics.include?(field) && NUMERIC_OPERATIONS.include?(operation)
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