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,313 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date"
4
+ require "bigdecimal"
5
+ require "time"
6
+
7
+ module AnalyticsOps
8
+ module Clients
9
+ # Optional official BigQuery client boundary. It exposes no arbitrary SQL API.
10
+ class BigQuery
11
+ PROJECT_ID = /\A[a-z][a-z0-9-]{4,28}[a-z0-9]\z/
12
+ DATASET = /\A[A-Za-z_][A-Za-z0-9_]{0,1023}\z/
13
+ RECIPES = {
14
+ "event_counts" => lambda do |table|
15
+ <<~SQL
16
+ SELECT event_name, COUNT(*) AS event_count
17
+ FROM `#{table}`
18
+ WHERE _TABLE_SUFFIX BETWEEN @start_suffix AND @end_suffix
19
+ GROUP BY event_name
20
+ ORDER BY event_count DESC
21
+ LIMIT @row_limit
22
+ SQL
23
+ end,
24
+ "daily_events" => lambda do |table|
25
+ <<~SQL
26
+ SELECT event_date, COUNT(*) AS event_count
27
+ FROM `#{table}`
28
+ WHERE _TABLE_SUFFIX BETWEEN @start_suffix AND @end_suffix
29
+ GROUP BY event_date
30
+ ORDER BY event_date
31
+ LIMIT @row_limit
32
+ SQL
33
+ end,
34
+ "page_views" => lambda do |table|
35
+ <<~SQL
36
+ SELECT
37
+ REGEXP_REPLACE(
38
+ (SELECT value.string_value FROM UNNEST(event_params) WHERE key = 'page_location'),
39
+ r'\\?.*$',
40
+ ''
41
+ ) AS page_location,
42
+ COUNT(*) AS page_views
43
+ FROM `#{table}`
44
+ WHERE _TABLE_SUFFIX BETWEEN @start_suffix AND @end_suffix
45
+ AND event_name = 'page_view'
46
+ GROUP BY page_location
47
+ ORDER BY page_views DESC
48
+ LIMIT @row_limit
49
+ SQL
50
+ end,
51
+ "event_sample" => lambda do |table|
52
+ <<~SQL
53
+ SELECT
54
+ event_date,
55
+ TIMESTAMP_MICROS(event_timestamp) AS event_time,
56
+ event_name,
57
+ platform,
58
+ device.category AS device_category
59
+ FROM `#{table}`
60
+ WHERE _TABLE_SUFFIX BETWEEN @start_suffix AND @end_suffix
61
+ ORDER BY event_timestamp DESC
62
+ LIMIT @row_limit
63
+ SQL
64
+ end
65
+ }.freeze
66
+
67
+ def initialize(settings:, service_account: nil, client: nil, timeout: nil, logger: nil)
68
+ @settings = validate_settings(settings)
69
+ @service_account = service_account
70
+ @client = client
71
+ @timeout = timeout
72
+ @logger = logger
73
+ end
74
+
75
+ def run(definition)
76
+ unless definition.is_a?(AnalyticsOps::BigQuery::Definition)
77
+ raise InvalidRequestError, "BigQuery report must be a BigQuery::Definition"
78
+ end
79
+
80
+ sql = RECIPES.fetch(definition.name).call(table_pattern)
81
+ params = {
82
+ start_suffix: definition.start_suffix,
83
+ end_suffix: definition.end_suffix,
84
+ row_limit: definition.limit
85
+ }
86
+ execute_query(definition.name, sql, params, definition.limit)
87
+ end
88
+
89
+ def run_funnel(definition, start_suffix:, end_suffix:)
90
+ unless definition.is_a?(Funnels::Definition)
91
+ raise InvalidRequestError, "funnel must be an AnalyticsOps::Funnels::Definition"
92
+ end
93
+ unless [start_suffix, end_suffix].all? { |value| value.is_a?(String) && value.match?(/\A\d{8}\z/) } &&
94
+ start_suffix <= end_suffix
95
+ raise InvalidRequestError, "Funnel date range is invalid"
96
+ end
97
+
98
+ sql, params = funnel_query(definition, start_suffix:, end_suffix:)
99
+ execute_query("funnel_#{definition.name}", sql, params, definition.steps.length)
100
+ end
101
+
102
+ private
103
+
104
+ def execute_query(name, sql, params, limit)
105
+ dry_job = query_job(sql, params:, dryrun: true)
106
+ bytes = job_bytes(dry_job)
107
+ maximum = @settings.fetch("maximum_bytes_billed")
108
+ if bytes > maximum
109
+ raise QuotaError, "BigQuery report would process #{bytes} bytes, above the configured #{maximum}-byte limit"
110
+ end
111
+
112
+ job = query_job(sql, params:, dryrun: false)
113
+ translate_errors { job.wait_until_done! }
114
+ raise RemoteError, "BigQuery report failed" if job.respond_to?(:failed?) && job.failed?
115
+
116
+ data = translate_errors { job.data }
117
+ rows = normalize_rows(data, limit)
118
+ headers = rows.first&.keys || schema_headers(data)
119
+ AnalyticsOps::BigQuery::Result.new(
120
+ name:,
121
+ headers:,
122
+ rows:,
123
+ bytes_processed: job_bytes(job, fallback: bytes),
124
+ maximum_bytes_billed: maximum
125
+ )
126
+ end
127
+
128
+ def funnel_query(definition, start_suffix:, end_suffix:)
129
+ params = {
130
+ start_suffix:,
131
+ end_suffix:,
132
+ event_names: definition.steps.map(&:event_name)
133
+ }
134
+ ctes = [funnel_base_cte]
135
+ ctes.concat(funnel_step_ctes(definition, params))
136
+ [funnel_sql(ctes, funnel_count_queries(definition)), params]
137
+ end
138
+
139
+ def funnel_base_cte
140
+ <<~SQL.chomp
141
+ base_events AS (
142
+ SELECT user_pseudo_id, event_name, event_timestamp
143
+ FROM `#{table_pattern}`
144
+ WHERE _TABLE_SUFFIX BETWEEN @start_suffix AND @end_suffix
145
+ AND user_pseudo_id IS NOT NULL
146
+ AND event_name IN UNNEST(@event_names)
147
+ )
148
+ SQL
149
+ end
150
+
151
+ def funnel_step_ctes(definition, params)
152
+ definition.steps.each_with_index do |step, index|
153
+ number = index + 1
154
+ params[:"step_#{number}_event"] = step.event_name
155
+ params[:"step_#{number}_name"] = step.name
156
+ end
157
+ definition.steps.each_index.map do |index|
158
+ number = index + 1
159
+ previous = number - 1
160
+ join = number == 1 ? "" : "JOIN step_#{previous} previous USING (user_pseudo_id)"
161
+ time_condition = number == 1 ? "" : "AND event_timestamp > previous.step_#{previous}_at"
162
+ <<~SQL.chomp
163
+ step_#{number} AS (
164
+ SELECT user_pseudo_id, MIN(event_timestamp) AS step_#{number}_at
165
+ FROM base_events
166
+ #{join}
167
+ WHERE event_name = @step_#{number}_event
168
+ #{time_condition}
169
+ GROUP BY user_pseudo_id
170
+ )
171
+ SQL
172
+ end
173
+ end
174
+
175
+ def funnel_count_queries(definition)
176
+ definition.steps.each_index.map do |index|
177
+ number = index + 1
178
+ "SELECT #{number} AS step, @step_#{number}_name AS step_name, COUNT(*) AS users FROM step_#{number}"
179
+ end
180
+ end
181
+
182
+ def funnel_sql(ctes, count_queries)
183
+ <<~SQL
184
+ WITH
185
+ #{ctes.join(",\n")}
186
+ , counts AS (
187
+ #{count_queries.join("\nUNION ALL\n")}
188
+ )
189
+ SELECT
190
+ step,
191
+ step_name,
192
+ users,
193
+ SAFE_DIVIDE(users, FIRST_VALUE(users) OVER (ORDER BY step)) AS completion_rate
194
+ FROM counts
195
+ ORDER BY step
196
+ SQL
197
+ end
198
+
199
+ def client
200
+ @client ||= begin
201
+ unless @service_account
202
+ raise AuthenticationError,
203
+ "Analytics Ops requires configured service-account credentials"
204
+ end
205
+
206
+ require "google/cloud/bigquery"
207
+ Google::Cloud::Bigquery.new(
208
+ project_id: @settings.fetch("project_id"),
209
+ credentials: @service_account.__send__(:credentials, access: :bigquery),
210
+ timeout: @timeout
211
+ )
212
+ rescue LoadError
213
+ raise UnsupportedCapabilityError,
214
+ "BigQuery support is optional; add `gem \"google-cloud-bigquery\", \"~> 1.64\"` and run bundle install"
215
+ end
216
+ end
217
+
218
+ def query_job(sql, params:, dryrun:)
219
+ SafeLogging.write(
220
+ @logger,
221
+ :info,
222
+ "google_bigquery_request",
223
+ "dry_run" => dryrun,
224
+ "project" => @settings.fetch("project_id")
225
+ )
226
+ options = {
227
+ params:,
228
+ dryrun:,
229
+ standard_sql: true,
230
+ maximum_bytes_billed: @settings.fetch("maximum_bytes_billed")
231
+ }
232
+ translate_errors do
233
+ client.query_job(sql, **options) do |job|
234
+ job.location = @settings.fetch("location") if job.respond_to?(:location=)
235
+ end
236
+ end
237
+ end
238
+
239
+ def table_pattern
240
+ "#{@settings.fetch("project_id")}.#{@settings.fetch("dataset")}.events_*"
241
+ end
242
+
243
+ def validate_settings(value)
244
+ unless value.is_a?(Hash) && value["experimental"] == true &&
245
+ PROJECT_ID.match?(value["project_id"].to_s) &&
246
+ DATASET.match?(value["dataset"].to_s) &&
247
+ value["maximum_bytes_billed"].is_a?(Integer) &&
248
+ value["maximum_bytes_billed"].positive?
249
+ raise ConfigurationError, "BigQuery configuration is invalid"
250
+ end
251
+
252
+ Canonical.immutable(value)
253
+ end
254
+
255
+ def job_bytes(job, fallback: 0)
256
+ value = job.respond_to?(:bytes_processed) ? job.bytes_processed : nil
257
+ value ||= fallback
258
+ return value if value.is_a?(Integer) && !value.negative?
259
+
260
+ raise RemoteError, "BigQuery returned an invalid processed-byte count"
261
+ end
262
+
263
+ def normalize_rows(data, limit)
264
+ rows = []
265
+ data.each do |row|
266
+ raise RemoteError, "BigQuery returned too many rows" if rows.length >= limit
267
+ raise RemoteError, "BigQuery returned an invalid row" unless row.respond_to?(:to_h)
268
+
269
+ rows << row.to_h.to_h { |key, value| [safe_header(key), safe_value(value)] }
270
+ end
271
+ rows
272
+ end
273
+
274
+ def schema_headers(data)
275
+ return [] unless data.respond_to?(:schema) && data.schema.respond_to?(:fields)
276
+
277
+ data.schema.fields.map { |field| safe_header(field.name) }
278
+ end
279
+
280
+ def safe_header(value)
281
+ string = value.to_s
282
+ return string if string.match?(/\A[A-Za-z_][A-Za-z0-9_]{0,127}\z/)
283
+
284
+ raise RemoteError, "BigQuery returned an invalid field name"
285
+ end
286
+
287
+ def safe_value(value)
288
+ case value
289
+ when String
290
+ raise RemoteError, "BigQuery returned invalid UTF-8" unless value.valid_encoding?
291
+
292
+ value
293
+ when Integer, TrueClass, FalseClass, NilClass
294
+ value
295
+ when Float
296
+ raise RemoteError, "BigQuery returned a non-finite value" unless value.finite?
297
+
298
+ value
299
+ when Date, DateTime, Time
300
+ value.iso8601
301
+ else
302
+ return value.to_s("F") if defined?(BigDecimal) && value.is_a?(BigDecimal)
303
+
304
+ raise RemoteError, "BigQuery returned an unsupported value"
305
+ end
306
+ end
307
+
308
+ def translate_errors(&)
309
+ ErrorTranslation.call(&)
310
+ end
311
+ end
312
+ end
313
+ end
@@ -1,9 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "data_result_normalization"
4
+
3
5
  module AnalyticsOps
4
6
  module Clients
5
7
  # Narrow adapter around Google's generated Data API client.
6
8
  class Data
9
+ include DataResultNormalization
10
+
7
11
  PACKAGE_REQUIREMENT = Gem::Requirement.new("~> 0.9.0")
8
12
  MATCH_TYPES = {
9
13
  "exact" => :EXACT,
@@ -72,10 +76,51 @@ module AnalyticsOps
72
76
  definitions.zip(reports).map { |definition, report| normalize_result(definition, report) }.freeze
73
77
  end
74
78
 
79
+ def metadata(property_id)
80
+ property = property_name(property_id)
81
+ @metadata_cache ||= {}
82
+ @metadata_cache[property] ||= begin
83
+ response = invoke(:get_metadata, name: "#{property}/metadata")
84
+ Reports::Metadata.new(
85
+ property_id:,
86
+ dimensions: array_field(response, :dimensions).map { |value| normalize_field(value, "dimension") },
87
+ metrics: array_field(response, :metrics).map { |value| normalize_field(value, "metric") }
88
+ )
89
+ end
90
+ end
91
+
92
+ def check_compatibility(property_id, definition)
93
+ unless definition.is_a?(Reports::Definition) && !definition.realtime?
94
+ raise InvalidRequestError, "compatibility requires a standard report definition"
95
+ end
96
+
97
+ response = invoke(
98
+ :check_compatibility,
99
+ property: property_name(property_id),
100
+ dimensions: definition.dimensions.map { |name| { name: } },
101
+ metrics: definition.metrics.map { |name| { name: } },
102
+ dimension_filter: filter_request(definition.dimension_filter),
103
+ metric_filter: filter_request(definition.metric_filter)
104
+ )
105
+ dimensions = compatibility_entries(response, :dimension_compatibilities)
106
+ metrics = compatibility_entries(response, :metric_compatibilities)
107
+ unless compatibility_names(dimensions) == definition.dimensions.sort &&
108
+ compatibility_names(metrics) == definition.metrics.sort
109
+ raise RemoteError, "Google Data API returned compatibility fields that do not match the request"
110
+ end
111
+
112
+ Reports::Compatibility.new(
113
+ report_name: definition.name,
114
+ dimensions:,
115
+ metrics:
116
+ )
117
+ end
118
+
75
119
  def available?
76
120
  generated_client = translate_errors { client }
77
121
  generated_client.respond_to?(:run_report) && generated_client.respond_to?(:run_realtime_report) &&
78
- generated_client.respond_to?(:batch_run_reports)
122
+ generated_client.respond_to?(:batch_run_reports) && generated_client.respond_to?(:get_metadata) &&
123
+ generated_client.respond_to?(:check_compatibility)
79
124
  end
80
125
 
81
126
  def compatibility
@@ -132,7 +177,7 @@ module AnalyticsOps
132
177
  dimensions: definition.dimensions.map { |name| { name: } },
133
178
  metrics: definition.metrics.map { |name| { name: } },
134
179
  dimension_filter: filter_request(definition.dimension_filter),
135
- metric_filter: metric_filter_request(definition.metric_filter),
180
+ metric_filter: filter_request(definition.metric_filter),
136
181
  order_bys: definition.order_bys.map { |order| order_request(order) },
137
182
  limit: definition.limit,
138
183
  return_property_quota: true
@@ -142,38 +187,62 @@ module AnalyticsOps
142
187
  def filter_request(filter)
143
188
  return nil unless filter
144
189
 
145
- {
146
- filter: {
147
- field_name: filter.fetch("field"),
148
- string_filter: {
149
- match_type: MATCH_TYPES.fetch(filter.fetch("match_type")),
150
- value: filter.fetch("value"),
151
- case_sensitive: filter.fetch("case_sensitive")
152
- }
153
- }
154
- }
190
+ if filter.key?("and")
191
+ return { and_group: { expressions: filter.fetch("and").map do |child|
192
+ filter_request(child)
193
+ end } }
194
+ end
195
+ if filter.key?("or")
196
+ return { or_group: { expressions: filter.fetch("or").map do |child|
197
+ filter_request(child)
198
+ end } }
199
+ end
200
+ return { not_expression: filter_request(filter.fetch("not")) } if filter.key?("not")
201
+
202
+ leaf_filter_request(filter)
155
203
  end
156
204
 
157
- def metric_filter_request(filter)
158
- return nil unless filter
205
+ def leaf_filter_request(filter)
206
+ request = { field_name: filter.fetch("field") }
207
+ if filter.key?("match_type")
208
+ request[:string_filter] = {
209
+ match_type: MATCH_TYPES.fetch(filter.fetch("match_type")),
210
+ value: filter.fetch("value"),
211
+ case_sensitive: filter.fetch("case_sensitive")
212
+ }
213
+ end
214
+ if filter.key?("in_list")
215
+ request[:in_list_filter] = {
216
+ values: filter.fetch("in_list"),
217
+ case_sensitive: filter.fetch("case_sensitive")
218
+ }
219
+ end
220
+ request[:numeric_filter] = numeric_filter_request(filter) if filter.key?("operation")
221
+ request[:between_filter] = between_filter_request(filter) if filter.key?("between")
222
+ request[:empty_filter] = {} if filter.key?("null")
223
+ { filter: request }
224
+ end
159
225
 
226
+ def numeric_filter_request(filter)
160
227
  number = filter.fetch("value")
161
- numeric_value = if number.is_a?(Integer)
162
- { int64_value: number }
163
- else
164
- { double_value: number }
165
- end
166
228
  {
167
- filter: {
168
- field_name: filter.fetch("field"),
169
- numeric_filter: {
170
- operation: NUMERIC_OPERATIONS.fetch(filter.fetch("operation")),
171
- value: numeric_value
172
- }
173
- }
229
+ operation: NUMERIC_OPERATIONS.fetch(filter.fetch("operation")),
230
+ value: numeric_value(number)
174
231
  }
175
232
  end
176
233
 
234
+ def between_filter_request(filter)
235
+ lower, upper = filter.fetch("between")
236
+ {
237
+ from_value: numeric_value(lower),
238
+ to_value: numeric_value(upper)
239
+ }
240
+ end
241
+
242
+ def numeric_value(number)
243
+ number.is_a?(Integer) ? { int64_value: number } : { double_value: number }
244
+ end
245
+
177
246
  def order_request(order)
178
247
  selector = order.key?("metric") ? :metric : :dimension
179
248
  name_key = selector == :metric ? :metric_name : :dimension_name
@@ -184,12 +253,13 @@ module AnalyticsOps
184
253
  end
185
254
 
186
255
  def invoke(method_name, request)
256
+ target = request[:property] || request[:name]
187
257
  SafeLogging.write(
188
258
  @logger,
189
259
  :info,
190
260
  "google_data_request",
191
261
  "method" => method_name.to_s,
192
- "property" => request.fetch(:property)
262
+ "property" => target
193
263
  )
194
264
  generated_client = translate_errors { client }
195
265
  unless generated_client.respond_to?(method_name)
@@ -214,45 +284,68 @@ module AnalyticsOps
214
284
  raise InvalidRequestError, "batch report definitions must have unique names"
215
285
  end
216
286
 
217
- def normalize_result(definition, response)
218
- dimension_headers = array_field(response, :dimension_headers).map do |header|
219
- remote_string(field(header, :name), "dimension header")
287
+ def normalize_field(value, kind)
288
+ api_name = remote_string(field(value, :api_name), "#{kind} API name")
289
+ ui_name = optional_string(field(value, :ui_name), "#{kind} UI name")
290
+ description = optional_string(field(value, :description), "#{kind} description")
291
+ category = optional_string(field(value, :category), "#{kind} category")
292
+ deprecated = array_field(value, :deprecated_api_names).map do |name|
293
+ remote_string(name, "deprecated #{kind} API name")
220
294
  end
221
- metric_headers = array_field(response, :metric_headers).map do |header|
222
- remote_string(field(header, :name), "metric header")
295
+ blocked = array_field(value, :blocked_reasons).map { |reason| enum_name(reason) }
296
+ Reports::Field.new(
297
+ api_name:,
298
+ ui_name:,
299
+ description:,
300
+ category:,
301
+ kind:,
302
+ custom_definition: metadata_boolean(field(value, :custom_definition)),
303
+ deprecated_api_names: deprecated,
304
+ data_type: optional_enum_name(field(value, :type)),
305
+ expression: optional_string(field(value, :expression), "#{kind} expression"),
306
+ blocked_reasons: blocked
307
+ )
308
+ end
309
+
310
+ def compatibility_entries(response, field_name)
311
+ array_field(response, field_name).map do |entry|
312
+ metadata = field(entry, field_name == :dimension_compatibilities ? :dimension_metadata : :metric_metadata)
313
+ {
314
+ "api_name" => remote_string(field(metadata, :api_name), "compatibility API name"),
315
+ "compatibility" => enum_name(field(entry, :compatibility))
316
+ }
223
317
  end
224
- expected_dimensions = definition.dimensions.dup
225
- expected_dimensions << "dateRange" if definition.date_ranges.length > 1
226
- unless dimension_headers == expected_dimensions && metric_headers == definition.metrics
227
- raise RemoteError, "Google Data API returned headers that do not match the requested report"
318
+ end
319
+
320
+ def compatibility_names(entries)
321
+ names = entries.map { |entry| entry.fetch("api_name") }
322
+ unless names.uniq.length == names.length
323
+ raise RemoteError,
324
+ "Google Data API returned duplicate compatibility fields"
228
325
  end
229
326
 
230
- rows = array_field(response, :rows).map do |row|
231
- dimensions = values(row, :dimension_values)
232
- metrics = values(row, :metric_values)
233
- unless dimensions.length == dimension_headers.length && metrics.length == metric_headers.length
234
- raise RemoteError, "Google Data API returned a row that does not match its headers"
235
- end
327
+ names.sort
328
+ end
236
329
 
237
- dimension_headers.zip(dimensions).to_h.merge(metric_headers.zip(metrics).to_h)
238
- end
330
+ def optional_enum_name(value)
331
+ return nil if value.nil?
239
332
 
240
- Reports::Result.new(
241
- name: definition.name,
242
- kind: definition.kind,
243
- dimension_headers:,
244
- metric_headers:,
245
- rows:,
246
- row_count: response_row_count(response, rows.length),
247
- metadata: metadata(response)
248
- )
333
+ enum_name(value)
334
+ end
335
+
336
+ def enum_name(value)
337
+ name = value.respond_to?(:name) ? value.name : value
338
+ string = name.to_s
339
+ raise RemoteError, "Google Data API returned an invalid enum" unless string.match?(/\A[A-Z][A-Z0-9_]*\z/)
340
+
341
+ string.downcase
249
342
  end
250
343
 
251
344
  def values(row, name)
252
345
  array_field(row, name).map { |value| remote_string(field(value, :value), "row value") }
253
346
  end
254
347
 
255
- def metadata(response)
348
+ def normalize_metadata(response)
256
349
  response_metadata = field(response, :metadata)
257
350
  result = {}
258
351
  if response_metadata
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AnalyticsOps
4
+ module Clients
5
+ # Strict normalization of generated Data API report responses.
6
+ module DataResultNormalization
7
+ private
8
+
9
+ def normalize_result(definition, response)
10
+ dimension_headers = response_headers(response, :dimension_headers, "dimension header")
11
+ metric_headers = response_headers(response, :metric_headers, "metric header")
12
+ validate_result_headers!(definition, dimension_headers, metric_headers)
13
+ response_rows = array_field(response, :rows)
14
+ if response_rows.length > definition.limit
15
+ raise RemoteError, "Google Data API returned more rows than the requested limit"
16
+ end
17
+
18
+ rows = response_rows.map { |row| normalize_result_row(row, dimension_headers, metric_headers) }
19
+ Reports::Result.new(
20
+ name: definition.name,
21
+ kind: definition.kind,
22
+ dimension_headers:,
23
+ metric_headers:,
24
+ rows:,
25
+ row_count: response_row_count(response, rows.length),
26
+ metadata: normalize_metadata(response)
27
+ )
28
+ end
29
+
30
+ def response_headers(response, collection, label)
31
+ array_field(response, collection).map do |header|
32
+ remote_string(field(header, :name), label)
33
+ end
34
+ end
35
+
36
+ def validate_result_headers!(definition, dimensions, metrics)
37
+ expected_dimensions = definition.dimensions.dup
38
+ expected_dimensions << "dateRange" if definition.date_ranges.length > 1
39
+ return if dimensions == expected_dimensions && metrics == definition.metrics
40
+
41
+ raise RemoteError, "Google Data API returned headers that do not match the requested report"
42
+ end
43
+
44
+ def normalize_result_row(row, dimension_headers, metric_headers)
45
+ dimensions = values(row, :dimension_values)
46
+ metrics = values(row, :metric_values)
47
+ unless dimensions.length == dimension_headers.length && metrics.length == metric_headers.length
48
+ raise RemoteError, "Google Data API returned a row that does not match its headers"
49
+ end
50
+
51
+ dimension_headers.zip(dimensions).to_h.merge(metric_headers.zip(metrics).to_h)
52
+ end
53
+ end
54
+ end
55
+ end
@@ -19,7 +19,12 @@ module AnalyticsOps
19
19
  translated = error_class(error.class.name)
20
20
  raise unless translated
21
21
 
22
- raise translated, Redaction.message(error.message)
22
+ raise translated.new(
23
+ Redaction.message(error.message),
24
+ remote_reason: structured_value(error, :reason),
25
+ remote_metadata: structured_value(error, :error_metadata),
26
+ remote_code: structured_value(error, :code)
27
+ )
23
28
  end
24
29
 
25
30
  def error_class(name)
@@ -39,6 +44,13 @@ module AnalyticsOps
39
44
  end
40
45
  end
41
46
  private_class_method :error_class
47
+
48
+ def structured_value(error, method)
49
+ error.public_send(method) if error.respond_to?(method)
50
+ rescue StandardError
51
+ nil
52
+ end
53
+ private_class_method :structured_value
42
54
  end
43
55
  private_constant :ErrorTranslation
44
56
  end