analytics_ops 0.1.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 (52) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +47 -0
  3. data/CODE_OF_CONDUCT.md +10 -0
  4. data/CONTRIBUTING.md +67 -0
  5. data/LICENSE.txt +21 -0
  6. data/README.md +188 -0
  7. data/SECURITY.md +56 -0
  8. data/docs/api-support-matrix.md +50 -0
  9. data/docs/architecture.md +89 -0
  10. data/docs/authentication.md +86 -0
  11. data/docs/commands.md +112 -0
  12. data/docs/configuration-schema-v1.json +129 -0
  13. data/docs/configuration.md +167 -0
  14. data/docs/google-client-compatibility.md +59 -0
  15. data/docs/plan-format.md +89 -0
  16. data/docs/plan-schema-v1.json +224 -0
  17. data/docs/rails.md +73 -0
  18. data/docs/reports.md +124 -0
  19. data/docs/safety.md +93 -0
  20. data/docs/troubleshooting.md +103 -0
  21. data/exe/analytics-ops +6 -0
  22. data/lib/analytics_ops/applier.rb +52 -0
  23. data/lib/analytics_ops/canonical.rb +65 -0
  24. data/lib/analytics_ops/cli.rb +427 -0
  25. data/lib/analytics_ops/clients/admin.rb +446 -0
  26. data/lib/analytics_ops/clients/data.rb +314 -0
  27. data/lib/analytics_ops/configuration/document.rb +25 -0
  28. data/lib/analytics_ops/configuration/loader.rb +76 -0
  29. data/lib/analytics_ops/configuration/schema.rb +107 -0
  30. data/lib/analytics_ops/configuration/validator.rb +344 -0
  31. data/lib/analytics_ops/configuration.rb +19 -0
  32. data/lib/analytics_ops/desired_state.rb +40 -0
  33. data/lib/analytics_ops/errors.rb +31 -0
  34. data/lib/analytics_ops/plan.rb +551 -0
  35. data/lib/analytics_ops/planner.rb +233 -0
  36. data/lib/analytics_ops/rails/railtie.rb +65 -0
  37. data/lib/analytics_ops/rails.rb +5 -0
  38. data/lib/analytics_ops/redaction.rb +46 -0
  39. data/lib/analytics_ops/reports/catalog.rb +100 -0
  40. data/lib/analytics_ops/reports/definition.rb +226 -0
  41. data/lib/analytics_ops/reports/result.rb +90 -0
  42. data/lib/analytics_ops/reports.rb +13 -0
  43. data/lib/analytics_ops/resources.rb +79 -0
  44. data/lib/analytics_ops/snapshot.rb +45 -0
  45. data/lib/analytics_ops/version.rb +5 -0
  46. data/lib/analytics_ops/workspace.rb +212 -0
  47. data/lib/analytics_ops.rb +21 -0
  48. data/lib/generators/analytics_ops/install_generator.rb +23 -0
  49. data/lib/generators/analytics_ops/templates/analytics-ops +9 -0
  50. data/lib/generators/analytics_ops/templates/analytics_ops.yml +23 -0
  51. data/sig/analytics_ops.rbs +395 -0
  52. metadata +131 -0
@@ -0,0 +1,233 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Deterministic read-only planning.
4
+
5
+ module AnalyticsOps
6
+ # Pure desired-vs-remote comparison. It never owns or contacts a client.
7
+ class Planner
8
+ def initialize(desired_state:, snapshot:)
9
+ @desired = desired_state
10
+ @snapshot = snapshot
11
+ @changes = []
12
+ @findings = []
13
+ end
14
+
15
+ def call
16
+ unless @desired.property_id == @snapshot.property_id
17
+ raise ConflictError,
18
+ "Configured property #{@desired.property_id} does not match snapshot #{@snapshot.property_id}"
19
+ end
20
+
21
+ plan_streams
22
+ plan_retention
23
+ plan_key_events
24
+ plan_custom_dimensions
25
+ plan_custom_metrics
26
+ add_manual_findings
27
+ add_experimental_findings
28
+
29
+ Plan.new(
30
+ profile: @desired.profile,
31
+ property_id: @desired.property_id,
32
+ snapshot_fingerprint: @snapshot.fingerprint,
33
+ changes: @changes,
34
+ findings: @findings
35
+ )
36
+ end
37
+
38
+ private
39
+
40
+ def plan_streams
41
+ remote = unique_index(@snapshot.streams, "data stream", &:id)
42
+ @desired.streams.each { |desired| plan_stream(remote, desired) }
43
+ end
44
+
45
+ def plan_stream(remote, desired)
46
+ stream = remote[desired.fetch("stream_id")]
47
+ identity = "stream:#{desired.fetch("stream_id")}"
48
+ unless stream
49
+ finding("drift", "missing_stream", identity,
50
+ "Configured data stream is not accessible; stream creation is not automatic")
51
+ return
52
+ end
53
+
54
+ plan_stream_uri(stream, desired, identity)
55
+ return unless desired["enhanced_measurement"]
56
+
57
+ finding(
58
+ "experimental",
59
+ "enhanced_measurement_not_managed",
60
+ identity,
61
+ "Enhanced Measurement is declared but remains an experimental, non-applicable capability"
62
+ )
63
+ end
64
+
65
+ def plan_stream_uri(stream, desired, identity)
66
+ return unless desired["default_uri"] && stream.default_uri != desired["default_uri"]
67
+
68
+ if stream.type == "web"
69
+ update(
70
+ "data_stream",
71
+ identity,
72
+ stream.to_h,
73
+ stream.to_h.merge("default_uri" => desired.fetch("default_uri")),
74
+ rollback: "Restore the previous web stream default URI"
75
+ )
76
+ else
77
+ finding("drift", "non_web_default_uri", identity, "Only web streams have a configurable default URI")
78
+ end
79
+ end
80
+
81
+ def plan_retention
82
+ desired = @desired.retention
83
+ return unless desired
84
+
85
+ remote = @snapshot.retention
86
+ unless remote
87
+ finding("drift", "retention_unavailable", "property:#{@desired.property_id}:retention",
88
+ "Data retention settings could not be discovered")
89
+ return
90
+ end
91
+
92
+ current = remote.to_h
93
+ after = current.merge(desired)
94
+ return if equivalent?(current, after, %w[event_data user_data reset_on_new_activity])
95
+
96
+ update(
97
+ "retention",
98
+ "property:#{@desired.property_id}:retention",
99
+ current,
100
+ after,
101
+ rollback: "Restore the previous event and user retention durations and reset behavior"
102
+ )
103
+ end
104
+
105
+ def plan_key_events
106
+ remote = unique_index(@snapshot.key_events, "key event", &:event_name)
107
+ @desired.key_events.each do |event_name|
108
+ next if remote.key?(event_name)
109
+
110
+ create(
111
+ "key_event",
112
+ "event:#{event_name}",
113
+ { "event_name" => event_name, "counting_method" => "once_per_event" },
114
+ rollback: "Delete the newly created key event in Google Analytics if rollback is required"
115
+ )
116
+ end
117
+ end
118
+
119
+ def plan_custom_dimensions
120
+ remote = unique_index(@snapshot.custom_dimensions, "custom dimension") do |dimension|
121
+ [dimension.scope, dimension.parameter_name]
122
+ end
123
+
124
+ @desired.custom_dimensions.each do |desired|
125
+ identity_key = [desired.fetch("scope"), desired.fetch("parameter_name")]
126
+ identity = identity_key.join(":").to_s
127
+ current = remote[identity_key]
128
+ unless current
129
+ create("custom_dimension", identity, desired, rollback: "Archive the newly created custom dimension")
130
+ next
131
+ end
132
+
133
+ before = current.to_h
134
+ mutable = %w[display_name description]
135
+ mutable << "disallow_ads_personalization" if desired.fetch("scope") == "user"
136
+ after = before.merge(desired.slice(*mutable))
137
+ next if equivalent?(before, after, mutable)
138
+
139
+ update("custom_dimension", identity, before, after, rollback: "Restore the previous custom dimension metadata")
140
+ end
141
+ end
142
+
143
+ def plan_custom_metrics
144
+ remote = unique_index(@snapshot.custom_metrics, "custom metric", &:parameter_name)
145
+
146
+ @desired.custom_metrics.each do |desired|
147
+ identity = desired.fetch("parameter_name")
148
+ current = remote[identity]
149
+ unless current
150
+ create("custom_metric", identity, desired, rollback: "Archive the newly created custom metric")
151
+ next
152
+ end
153
+
154
+ before = current.to_h
155
+ if before.fetch("scope") != desired.fetch("scope") ||
156
+ before.fetch("measurement_unit") != desired.fetch("measurement_unit")
157
+ finding("drift", "immutable_metric_conflict", "metric:#{identity}",
158
+ "Custom metric scope or measurement unit differs and will not be recreated automatically")
159
+ next
160
+ end
161
+
162
+ mutable = %w[display_name description]
163
+ after = before.merge(desired.slice(*mutable))
164
+ next if equivalent?(before, after, mutable)
165
+
166
+ update("custom_metric", identity, before, after, rollback: "Restore the previous custom metric metadata")
167
+ end
168
+ end
169
+
170
+ def add_manual_findings
171
+ @desired.manual_requirements.each do |requirement|
172
+ finding("manual", "manual_requirement", requirement, "Verify #{requirement.tr("_", " ")} in Google Analytics")
173
+ end
174
+ end
175
+
176
+ def add_experimental_findings
177
+ return unless @desired.google_signals
178
+
179
+ finding(
180
+ "experimental",
181
+ "google_signals_not_managed",
182
+ "property:#{@desired.property_id}:google_signals",
183
+ "Google Signals is declared but remains an experimental, non-applicable capability"
184
+ )
185
+ end
186
+
187
+ def create(resource_type, identity, after, rollback:)
188
+ @changes << Plan::Change.new(
189
+ resource_type:,
190
+ resource_identity: identity,
191
+ operation: "create",
192
+ api_maturity: "beta",
193
+ before: nil,
194
+ after: Canonical.normalize(after),
195
+ reversible: true,
196
+ rollback:
197
+ )
198
+ end
199
+
200
+ def update(resource_type, identity, before, after, rollback:)
201
+ @changes << Plan::Change.new(
202
+ resource_type:,
203
+ resource_identity: identity,
204
+ operation: "update",
205
+ api_maturity: "beta",
206
+ before: Canonical.normalize(before),
207
+ after: Canonical.normalize(after),
208
+ reversible: true,
209
+ rollback:
210
+ )
211
+ end
212
+
213
+ def finding(severity, code, identity, message)
214
+ @findings << Plan::Finding.new(severity:, code:, resource_identity: identity, message:)
215
+ end
216
+
217
+ def unique_index(values, resource)
218
+ values.each_with_object({}) do |value, result|
219
+ identity = yield(value)
220
+ if result.key?(identity)
221
+ raise ConflictError,
222
+ "Ambiguous remote #{resource} identity #{Array(identity).join(":")}"
223
+ end
224
+
225
+ result[identity] = value
226
+ end
227
+ end
228
+
229
+ def equivalent?(before, after, keys)
230
+ keys.all? { |key| before[key] == after[key] }
231
+ end
232
+ end
233
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AnalyticsOps
4
+ # Helpers used only when an operator explicitly invokes a Rails Rake task.
5
+ module RailsTasks
6
+ module_function
7
+
8
+ def run(command, report_name: nil)
9
+ arguments = [
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)
13
+ ]
14
+ if command == "plan"
15
+ arguments.push("--output",
16
+ ENV.fetch("ANALYTICS_OPS_PLAN", Rails.root.join("tmp/analytics_ops-plan.json").to_s))
17
+ end
18
+ arguments << report_name if report_name
19
+
20
+ status = CLI.start(arguments)
21
+ return if status == CLI::SUCCESS
22
+
23
+ raise Error, "analytics-ops #{command} exited with status #{status}"
24
+ end
25
+ end
26
+
27
+ # Optional Rails hooks. Defining this class performs no Google API calls.
28
+ class Railtie < Rails::Railtie
29
+ generators do
30
+ require_relative "../../generators/analytics_ops/install_generator"
31
+ end
32
+
33
+ rake_tasks do
34
+ namespace :analytics do
35
+ desc "Validate Analytics Ops configuration, credentials, and API access"
36
+ task doctor: :environment do
37
+ RailsTasks.run("doctor")
38
+ end
39
+
40
+ desc "Audit configured Google Analytics state without changing it"
41
+ task audit: :environment do
42
+ RailsTasks.run("audit")
43
+ end
44
+
45
+ desc "Write a deterministic Analytics Ops plan"
46
+ task plan: :environment do
47
+ RailsTasks.run("plan")
48
+ end
49
+
50
+ desc "Verify that managed Google Analytics state converges"
51
+ task verify: :environment do
52
+ RailsTasks.run("verify")
53
+ end
54
+
55
+ desc "Run a built-in report: rake analytics:report[NAME]"
56
+ task :report, [:name] => :environment do |_task, arguments|
57
+ name = arguments[:name]
58
+ raise Error, "analytics:report requires NAME" if name.to_s.empty?
59
+
60
+ RailsTasks.run("report", report_name: name)
61
+ end
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../analytics_ops"
4
+ require "rails/railtie"
5
+ require_relative "rails/railtie"
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module AnalyticsOps
6
+ # Removes credential-shaped material before text reaches user-visible output.
7
+ module Redaction
8
+ BEARER = /\bBearer\s+[^\s,;]+/i
9
+ AUTHORIZATION = /\bAuthorization\s*[:=]\s*[^\r\n,;]+/i
10
+ PRIVATE_KEY = /-----BEGIN[^-]*PRIVATE KEY-----.*?-----END[^-]*PRIVATE KEY-----/mi
11
+ SECRET_ASSIGNMENT = /
12
+ (
13
+ access[_ -]?token|refresh[_ -]?token|client[_ -]?(?:id|secret)|
14
+ private[_ -]?key|password|api[_ -]?key|credentials?
15
+ )
16
+ \s*([:=]\s*|=>\s*)["']?[^\s,"';}]+
17
+ /ix
18
+ CONTROL_CHARACTERS = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/
19
+
20
+ module_function
21
+
22
+ def message(value)
23
+ value.to_s
24
+ .gsub(PRIVATE_KEY, "[REDACTED]")
25
+ .gsub(AUTHORIZATION, "Authorization: [REDACTED]")
26
+ .gsub(BEARER, "Bearer [REDACTED]")
27
+ .gsub(SECRET_ASSIGNMENT) { "#{Regexp.last_match(1)}=[REDACTED]" }
28
+ .gsub(CONTROL_CHARACTERS, "?")
29
+ .slice(0, 1_000)
30
+ end
31
+ end
32
+
33
+ # Writes small structured operational events without requests, responses, or credentials.
34
+ module SafeLogging
35
+ module_function
36
+
37
+ def write(logger, level, event, details = {})
38
+ return unless logger.respond_to?(level)
39
+
40
+ payload = { "source" => "analytics_ops", "event" => event }.merge(details)
41
+ logger.public_send(level, Redaction.message(JSON.generate(payload)))
42
+ rescue StandardError
43
+ nil
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AnalyticsOps
4
+ module Reports
5
+ # Small built-in catalog focused on acquisition and calculator outcomes.
6
+ module Catalog
7
+ STANDARD_DATE_RANGE = [{ "start_date" => "28daysAgo", "end_date" => "yesterday" }].freeze
8
+ CALCULATOR = "customEvent:calculator_slug"
9
+ EVENT_FILTER = lambda do |value, match_type = "exact"|
10
+ { "field" => "eventName", "match_type" => match_type, "value" => value }
11
+ end
12
+
13
+ DEFINITIONS = [
14
+ Definition.new(
15
+ name: "traffic_acquisition",
16
+ kind: "standard",
17
+ dimensions: %w[sessionDefaultChannelGroup sessionSource sessionMedium],
18
+ metrics: %w[sessions totalUsers keyEvents],
19
+ date_ranges: STANDARD_DATE_RANGE,
20
+ order_bys: [{ "metric" => "sessions", "desc" => true }],
21
+ limit: 250
22
+ ),
23
+ Definition.new(
24
+ name: "landing_pages",
25
+ kind: "standard",
26
+ dimensions: %w[landingPagePlusQueryString],
27
+ metrics: %w[sessions totalUsers keyEvents],
28
+ date_ranges: STANDARD_DATE_RANGE,
29
+ order_bys: [{ "metric" => "sessions", "desc" => true }],
30
+ limit: 500
31
+ ),
32
+ Definition.new(
33
+ name: "calculator_completions",
34
+ kind: "standard",
35
+ dimensions: ["eventName", CALCULATOR],
36
+ metrics: %w[eventCount totalUsers],
37
+ date_ranges: STANDARD_DATE_RANGE,
38
+ dimension_filter: EVENT_FILTER.call("calculation_completed"),
39
+ order_bys: [{ "metric" => "eventCount", "desc" => true }],
40
+ limit: 500
41
+ ),
42
+ Definition.new(
43
+ name: "shares_and_prints",
44
+ kind: "standard",
45
+ dimensions: ["eventName", CALCULATOR],
46
+ metrics: %w[eventCount totalUsers],
47
+ date_ranges: STANDARD_DATE_RANGE,
48
+ dimension_filter: EVENT_FILTER.call("^(result_shared|result_printed)$", "full_regexp"),
49
+ order_bys: [{ "metric" => "eventCount", "desc" => true }],
50
+ limit: 500
51
+ ),
52
+ Definition.new(
53
+ name: "related_calculator_navigation",
54
+ kind: "standard",
55
+ dimensions: ["eventName", CALCULATOR, "customEvent:related_calculator_slug"],
56
+ metrics: %w[eventCount totalUsers],
57
+ date_ranges: STANDARD_DATE_RANGE,
58
+ dimension_filter: EVENT_FILTER.call("related_calculator_clicked"),
59
+ order_bys: [{ "metric" => "eventCount", "desc" => true }],
60
+ limit: 500
61
+ ),
62
+ Definition.new(
63
+ name: "commercial_outbound_clicks",
64
+ kind: "standard",
65
+ dimensions: ["eventName", CALCULATOR, "customEvent:outbound_destination"],
66
+ metrics: %w[eventCount totalUsers],
67
+ date_ranges: STANDARD_DATE_RANGE,
68
+ dimension_filter: EVENT_FILTER.call("commercial_outbound_clicked"),
69
+ order_bys: [{ "metric" => "eventCount", "desc" => true }],
70
+ limit: 500
71
+ ),
72
+ Definition.new(
73
+ name: "realtime_events",
74
+ kind: "realtime",
75
+ dimensions: %w[eventName],
76
+ metrics: %w[eventCount activeUsers],
77
+ order_bys: [{ "metric" => "eventCount", "desc" => true }],
78
+ limit: 100
79
+ )
80
+ ].to_h { |definition| [definition.name, definition] }.freeze
81
+
82
+ module_function
83
+
84
+ def fetch(name, kind: nil)
85
+ definition = DEFINITIONS.fetch(name.to_s) do
86
+ raise InvalidRequestError, "Unknown report #{name.inspect}; available reports: #{names.join(", ")}"
87
+ end
88
+ if kind && definition.kind != kind.to_s
89
+ raise InvalidRequestError, "Report #{name} is #{definition.kind}, not #{kind}"
90
+ end
91
+
92
+ definition
93
+ end
94
+
95
+ def names
96
+ DEFINITIONS.keys.sort
97
+ end
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,226 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date"
4
+
5
+ module AnalyticsOps
6
+ module Reports
7
+ # Immutable, gem-owned description of one Data API query.
8
+ class Definition
9
+ KINDS = %w[standard realtime].freeze
10
+ API_NAME = /\A[a-z][a-zA-Z0-9_]*(?::[a-zA-Z][a-zA-Z0-9_]*)?\z/
11
+ REPORT_NAME = /\A[a-z][a-z0-9_]{0,63}\z/
12
+ RANGE_NAME = /\A[a-z][a-z0-9_]{0,39}\z/
13
+ RELATIVE_DATE = /\A(\d{1,4})daysAgo\z/
14
+ MATCH_TYPES = %w[exact begins_with ends_with contains full_regexp partial_regexp].freeze
15
+ NUMERIC_OPERATIONS = %w[equal less_than less_than_or_equal greater_than greater_than_or_equal].freeze
16
+ MAX_LIMIT = 100_000
17
+
18
+ attr_reader :name, :kind, :dimensions, :metrics, :date_ranges,
19
+ :dimension_filter, :metric_filter, :order_bys, :offset, :limit
20
+
21
+ def initialize(name:, kind:, dimensions:, metrics:, date_ranges: [], dimension_filter: nil,
22
+ metric_filter: nil, order_bys: [], offset: 0, limit: 100)
23
+ @name = validate_name(name)
24
+ @kind = validate_kind(kind)
25
+ @dimensions = api_names(dimensions, "dimensions", maximum: realtime_kind?(kind) ? 4 : 9)
26
+ @metrics = api_names(metrics, "metrics", maximum: 10)
27
+ @date_ranges = ranges(date_ranges)
28
+ @dimension_filter = string_filter(dimension_filter)
29
+ @metric_filter = numeric_filter(metric_filter)
30
+ @order_bys = orders(order_bys)
31
+ @offset = integer(offset, "offset", minimum: 0, maximum: 1_000_000_000)
32
+ @limit = integer(limit, "limit", minimum: 1, maximum: MAX_LIMIT)
33
+ validate_shape!
34
+ freeze
35
+ end
36
+
37
+ def realtime?
38
+ kind == "realtime"
39
+ end
40
+
41
+ def to_h
42
+ {
43
+ "name" => name,
44
+ "kind" => kind,
45
+ "dimensions" => dimensions,
46
+ "metrics" => metrics,
47
+ "date_ranges" => date_ranges,
48
+ "dimension_filter" => dimension_filter,
49
+ "metric_filter" => metric_filter,
50
+ "order_bys" => order_bys,
51
+ "offset" => offset,
52
+ "limit" => limit
53
+ }
54
+ end
55
+
56
+ private
57
+
58
+ def validate_name(value)
59
+ return value.dup.freeze if value.is_a?(String) && REPORT_NAME.match?(value)
60
+
61
+ raise InvalidRequestError, "Report name is invalid"
62
+ end
63
+
64
+ def validate_kind(value)
65
+ return value.dup.freeze if value.is_a?(String) && KINDS.include?(value)
66
+
67
+ raise InvalidRequestError, "Report kind must be standard or realtime"
68
+ end
69
+
70
+ def realtime_kind?(value)
71
+ value == "realtime"
72
+ end
73
+
74
+ def api_names(values, label, maximum:)
75
+ unless values.is_a?(Array) && values.any? && values.length <= maximum &&
76
+ values.all? { |value| valid_api_name?(value) }
77
+ raise InvalidRequestError, "Report #{label} must contain 1 to #{maximum} valid Data API names"
78
+ end
79
+ unless values.uniq.length == values.length
80
+ raise InvalidRequestError,
81
+ "Report #{label} must not contain duplicates"
82
+ end
83
+
84
+ Canonical.immutable(values)
85
+ end
86
+
87
+ def valid_api_name?(value)
88
+ value.is_a?(String) && value.length <= 128 && API_NAME.match?(value)
89
+ end
90
+
91
+ def ranges(values)
92
+ raise InvalidRequestError, "date_ranges must be an array" unless values.is_a?(Array)
93
+ raise InvalidRequestError, "A report can contain at most four date ranges" if values.length > 4
94
+
95
+ normalized = values.map do |range|
96
+ hash = string_hash(range, "date range", %w[start_date end_date name])
97
+ start_date = date(hash.fetch("start_date") { invalid!("Missing start_date") })
98
+ end_date = date(hash.fetch("end_date") { invalid!("Missing end_date") })
99
+ validate_date_order!(start_date, end_date)
100
+
101
+ result = { "start_date" => start_date, "end_date" => end_date }
102
+ if hash.key?("name")
103
+ range_name = hash.fetch("name")
104
+ invalid!("Date range name is invalid") unless range_name.is_a?(String) && RANGE_NAME.match?(range_name)
105
+ result["name"] = range_name
106
+ end
107
+ result
108
+ end
109
+ Canonical.immutable(normalized)
110
+ end
111
+
112
+ def date(value)
113
+ invalid!("Invalid report date") unless value.is_a?(String)
114
+ return value if %w[today yesterday].include?(value)
115
+
116
+ relative = RELATIVE_DATE.match(value)
117
+ return value if relative && relative[1].to_i <= 3650
118
+
119
+ Date.iso8601(value)
120
+ value
121
+ rescue Date::Error
122
+ invalid!("Invalid report date")
123
+ end
124
+
125
+ def validate_date_order!(start_date, end_date)
126
+ return unless start_date.match?(/\A\d{4}-/) && end_date.match?(/\A\d{4}-/)
127
+ return if Date.iso8601(start_date) <= Date.iso8601(end_date)
128
+
129
+ invalid!("Report start_date must not be after end_date")
130
+ end
131
+
132
+ def string_filter(value)
133
+ return nil if value.nil?
134
+
135
+ hash = string_hash(value, "dimension filter", %w[field match_type value case_sensitive])
136
+ field = hash.fetch("field") { invalid!("Missing filter field") }
137
+ match_type = hash.fetch("match_type") { invalid!("Missing filter match_type") }
138
+ filter_value = hash.fetch("value") { invalid!("Missing filter value") }
139
+ unless valid_api_name?(field) && dimensions.include?(field) && MATCH_TYPES.include?(match_type) &&
140
+ printable_string?(filter_value, maximum: 1_024)
141
+ invalid!("Dimension filter is invalid or references an unselected dimension")
142
+ end
143
+
144
+ case_sensitive = hash.key?("case_sensitive") ? boolean(hash.fetch("case_sensitive"), "case_sensitive") : false
145
+ Canonical.immutable(
146
+ "field" => field,
147
+ "match_type" => match_type,
148
+ "value" => filter_value,
149
+ "case_sensitive" => case_sensitive
150
+ )
151
+ end
152
+
153
+ def numeric_filter(value)
154
+ return nil if value.nil?
155
+
156
+ hash = string_hash(value, "metric filter", %w[field operation value])
157
+ field = hash.fetch("field") { invalid!("Missing metric filter field") }
158
+ operation = hash.fetch("operation") { invalid!("Missing metric filter operation") }
159
+ number = hash.fetch("value") { invalid!("Missing metric filter value") }
160
+ valid = valid_api_name?(field) && metrics.include?(field) &&
161
+ NUMERIC_OPERATIONS.include?(operation) && number.is_a?(Numeric)
162
+ invalid!("Metric filter is invalid or references an unselected metric") unless valid
163
+ invalid!("Metric filter value must be finite") unless number.finite?
164
+
165
+ Canonical.immutable("field" => field, "operation" => operation, "value" => number)
166
+ end
167
+
168
+ def orders(values)
169
+ raise InvalidRequestError, "order_bys must be an array" unless values.is_a?(Array)
170
+ raise InvalidRequestError, "A report can contain at most ten order clauses" if values.length > 10
171
+
172
+ normalized = values.map do |order|
173
+ hash = string_hash(order, "report order", %w[metric dimension desc])
174
+ selectors = %w[metric dimension].select { |key| hash.key?(key) }
175
+ invalid!("Report order requires exactly one metric or dimension") unless selectors.length == 1
176
+
177
+ selector = selectors.first
178
+ api_name = hash.fetch(selector)
179
+ selected = selector == "metric" ? metrics : dimensions
180
+ unless valid_api_name?(api_name) && selected.include?(api_name)
181
+ invalid!("Report order references an unselected #{selector}")
182
+ end
183
+
184
+ { selector => api_name, "desc" => hash.key?("desc") ? boolean(hash.fetch("desc"), "desc") : false }
185
+ end
186
+ Canonical.immutable(normalized)
187
+ end
188
+
189
+ def validate_shape!
190
+ invalid!("Realtime reports cannot include date ranges") if realtime? && !date_ranges.empty?
191
+ invalid!("Realtime reports do not support offset") if realtime? && offset.positive?
192
+ invalid!("Standard reports require a date range") if !realtime? && date_ranges.empty?
193
+ end
194
+
195
+ def string_hash(value, label, allowed)
196
+ raise InvalidRequestError, "#{label} must be an object" unless value.is_a?(Hash)
197
+ raise InvalidRequestError, "#{label} keys must be strings" unless value.keys.all?(String)
198
+
199
+ unknown = value.keys - allowed
200
+ raise InvalidRequestError, "Unknown #{label} field #{unknown.first}" unless unknown.empty?
201
+
202
+ value
203
+ end
204
+
205
+ def printable_string?(value, maximum:)
206
+ value.is_a?(String) && !value.empty? && value.length <= maximum && !value.match?(/[\u0000-\u001f\u007f]/)
207
+ end
208
+
209
+ def boolean(value, label)
210
+ return value if [true, false].include?(value)
211
+
212
+ raise InvalidRequestError, "#{label} must be true or false"
213
+ end
214
+
215
+ def integer(value, label, minimum:, maximum:)
216
+ return value if value.is_a?(Integer) && value.between?(minimum, maximum)
217
+
218
+ raise InvalidRequestError, "Report #{label} must be between #{minimum} and #{maximum}"
219
+ end
220
+
221
+ def invalid!(message)
222
+ raise InvalidRequestError, message
223
+ end
224
+ end
225
+ end
226
+ end