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,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:
|
|
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
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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
|
|
158
|
-
|
|
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
|
-
|
|
168
|
-
|
|
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" =>
|
|
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
|
|
218
|
-
|
|
219
|
-
|
|
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
|
-
|
|
222
|
-
|
|
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
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
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
|
-
|
|
231
|
-
|
|
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
|
-
|
|
238
|
-
|
|
330
|
+
def optional_enum_name(value)
|
|
331
|
+
return nil if value.nil?
|
|
239
332
|
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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
|
|
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
|
|
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
|