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,446 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Official Admin API boundary.
4
+
5
+ module AnalyticsOps
6
+ module Clients
7
+ # Narrow adapter around Google's generated Admin client.
8
+ class Admin
9
+ PACKAGE_REQUIREMENT = Gem::Requirement.new("~> 0.8.0")
10
+ RETENTION_TO_GOOGLE = {
11
+ "2_months" => :TWO_MONTHS,
12
+ "14_months" => :FOURTEEN_MONTHS,
13
+ "26_months" => :TWENTY_SIX_MONTHS,
14
+ "38_months" => :THIRTY_EIGHT_MONTHS,
15
+ "50_months" => :FIFTY_MONTHS
16
+ }.freeze
17
+ GOOGLE_TO_RETENTION = RETENTION_TO_GOOGLE.invert.freeze
18
+ STREAM_TYPES = {
19
+ "WEB_DATA_STREAM" => "web",
20
+ "ANDROID_APP_DATA_STREAM" => "android",
21
+ "IOS_APP_DATA_STREAM" => "ios"
22
+ }.freeze
23
+ CAPABILITIES = {
24
+ "account_property_discovery" => :list_account_summaries,
25
+ "data_stream_discovery" => :list_data_streams,
26
+ "data_retention" => :update_data_retention_settings,
27
+ "key_events" => :create_key_event,
28
+ "custom_dimensions" => :create_custom_dimension,
29
+ "custom_metrics" => :create_custom_metric
30
+ }.freeze
31
+
32
+ def initialize(client: nil, credentials: nil, transport: :grpc, timeout: nil, logger: nil)
33
+ raise ConfigurationError, "transport must be grpc or rest" unless %i[grpc rest].include?(transport.to_sym)
34
+
35
+ @client = client
36
+ @credentials = credentials
37
+ @transport = transport.to_sym
38
+ @timeout = timeout
39
+ @logger = logger
40
+ end
41
+
42
+ def discover
43
+ list(:list_account_summaries, page_size: 200).map do |summary|
44
+ properties = array_field(summary, :property_summaries).map do |property|
45
+ normalized = normalize_property(property, can_edit: field(property, :can_edit))
46
+ streams = list_streams(normalized.id).map(&:to_h)
47
+ normalized.to_h.merge("streams" => streams)
48
+ end
49
+ properties.sort_by! { |property| property.fetch("id") }
50
+
51
+ Resources::Account.new(
52
+ id: resource_id(field(summary, :account)),
53
+ name: field(summary, :account).to_s,
54
+ display_name: field(summary, :display_name).to_s,
55
+ properties:
56
+ )
57
+ end.sort_by(&:id)
58
+ end
59
+
60
+ def property_access(property_id)
61
+ id = property_id.to_s
62
+ list(:list_account_summaries, page_size: 200).each do |summary|
63
+ array_field(summary, :property_summaries).each do |property|
64
+ next unless resource_id(field(property, :property)) == id
65
+
66
+ return normalize_property(property, can_edit: field(property, :can_edit))
67
+ end
68
+ end
69
+
70
+ raise AuthorizationError, "Configured property is not present in accessible account summaries"
71
+ end
72
+
73
+ def snapshot(property_id)
74
+ property_name = property_name(property_id)
75
+ Snapshot.new(
76
+ property: normalize_property(get(:get_property, name: property_name), can_edit: nil),
77
+ streams: list_streams(property_id),
78
+ retention: normalize_retention(get(:get_data_retention_settings,
79
+ name: "#{property_name}/dataRetentionSettings")),
80
+ key_events: list(:list_key_events, parent: property_name, page_size: 200).map do |value|
81
+ normalize_key_event(value)
82
+ end,
83
+ custom_dimensions: list(:list_custom_dimensions, parent: property_name, page_size: 200)
84
+ .map { |value| normalize_custom_dimension(value) },
85
+ custom_metrics: list(:list_custom_metrics, parent: property_name, page_size: 200)
86
+ .map { |value| normalize_custom_metric(value) }
87
+ )
88
+ end
89
+
90
+ def capabilities
91
+ CAPABILITIES.to_h { |name, method| [name, client.respond_to?(method)] }.freeze
92
+ end
93
+
94
+ def compatibility
95
+ specification = Gem::Specification.find_by_name("google-analytics-admin")
96
+ {
97
+ "package" => specification.name,
98
+ "version" => specification.version.to_s,
99
+ "requirement" => PACKAGE_REQUIREMENT.to_s,
100
+ "supported" => PACKAGE_REQUIREMENT.satisfied_by?(specification.version),
101
+ "transport" => @transport.to_s
102
+ }.freeze
103
+ rescue Gem::LoadError => error
104
+ raise UnsupportedCapabilityError, Redaction.message(error.message)
105
+ end
106
+
107
+ def apply_change(change, property_id:)
108
+ method_name, request = mutation(change, property_id.to_s)
109
+ get(method_name, request)
110
+ change
111
+ end
112
+
113
+ private
114
+
115
+ def client
116
+ @client ||= begin
117
+ require "google/analytics/admin"
118
+ Google::Analytics::Admin.analytics_admin_service(transport: @transport) do |config|
119
+ config.credentials = @credentials if @credentials
120
+ config.timeout = @timeout if @timeout
121
+ end
122
+ end
123
+ end
124
+
125
+ def list_streams(property_id)
126
+ list(:list_data_streams, parent: property_name(property_id), page_size: 200).map do |stream|
127
+ normalize_stream(stream)
128
+ end
129
+ end
130
+
131
+ def list(method_name, request)
132
+ response = invoke(method_name, request)
133
+ response.respond_to?(:to_a) ? response.to_a : Array(response)
134
+ end
135
+
136
+ def get(method_name, request)
137
+ invoke(method_name, request)
138
+ end
139
+
140
+ def invoke(method_name, request)
141
+ SafeLogging.write(
142
+ @logger,
143
+ :info,
144
+ "google_admin_request",
145
+ "method" => method_name.to_s,
146
+ "resource" => request_resource(request)
147
+ )
148
+ generated_client = translate_errors { client }
149
+ unless generated_client.respond_to?(method_name)
150
+ raise UnsupportedCapabilityError, "Installed Google Admin client does not support #{method_name}"
151
+ end
152
+
153
+ translate_errors { generated_client.public_send(method_name, request) }
154
+ end
155
+
156
+ def request_resource(request)
157
+ request[:name] || request[:parent] || request.dig(:data_stream, :name) ||
158
+ request.dig(:data_retention_settings, :name)
159
+ end
160
+
161
+ def translate_errors
162
+ yield
163
+ rescue AnalyticsOps::Error
164
+ raise
165
+ rescue StandardError => error
166
+ translated = case error.class.name
167
+ when /Unauthenticated|Google::Auth|Signet::Authorization/
168
+ AuthenticationError
169
+ when /PermissionDenied|Forbidden/
170
+ AuthorizationError
171
+ when /ResourceExhausted|TooManyRequests/
172
+ QuotaError
173
+ when /DeadlineExceeded|Timeout|ETIMEDOUT/
174
+ TimeoutError
175
+ when /InvalidArgument|FailedPrecondition|NotFound|AlreadyExists/
176
+ InvalidRequestError
177
+ when /Google::Cloud::|GRPC::|Faraday::|HTTP/
178
+ RemoteError
179
+ end
180
+ raise unless translated
181
+
182
+ raise translated, Redaction.message(error.message)
183
+ end
184
+
185
+ def normalize_property(value, can_edit:)
186
+ name = field(value, :property) || field(value, :name)
187
+ Resources::Property.new(
188
+ id: resource_id(name),
189
+ name: name.to_s,
190
+ display_name: field(value, :display_name).to_s,
191
+ parent: optional_string(field(value, :parent)),
192
+ property_type: normalize_enum(field(value, :property_type), prefix: "PROPERTY_TYPE_"),
193
+ can_edit:
194
+ )
195
+ end
196
+
197
+ def normalize_stream(value)
198
+ name = field(value, :name).to_s
199
+ type = STREAM_TYPES.fetch(enum_name(field(value, :type)), "unspecified")
200
+ web_data = field(value, :web_stream_data)
201
+ Resources::DataStream.new(
202
+ id: resource_id(name),
203
+ name:,
204
+ display_name: field(value, :display_name).to_s,
205
+ type:,
206
+ default_uri: optional_string(field(web_data, :default_uri)),
207
+ measurement_id: optional_string(field(web_data, :measurement_id))
208
+ )
209
+ end
210
+
211
+ def normalize_retention(value)
212
+ Resources::Retention.new(
213
+ name: field(value, :name).to_s,
214
+ event_data: normalize_retention_value(field(value, :event_data_retention)),
215
+ user_data: normalize_retention_value(field(value, :user_data_retention)),
216
+ reset_on_new_activity: boolean?(field(value, :reset_user_data_on_new_activity))
217
+ )
218
+ end
219
+
220
+ def normalize_key_event(value)
221
+ Resources::KeyEvent.new(
222
+ name: field(value, :name).to_s,
223
+ event_name: field(value, :event_name).to_s,
224
+ counting_method: normalize_enum(field(value, :counting_method))
225
+ )
226
+ end
227
+
228
+ def normalize_custom_dimension(value)
229
+ Resources::CustomDimension.new(
230
+ name: field(value, :name).to_s,
231
+ parameter_name: field(value, :parameter_name).to_s,
232
+ display_name: field(value, :display_name).to_s,
233
+ description: field(value, :description).to_s,
234
+ scope: normalize_enum(field(value, :scope), prefix: "DIMENSION_SCOPE_"),
235
+ disallow_ads_personalization: boolean?(field(value, :disallow_ads_personalization))
236
+ )
237
+ end
238
+
239
+ def normalize_custom_metric(value)
240
+ Resources::CustomMetric.new(
241
+ name: field(value, :name).to_s,
242
+ parameter_name: field(value, :parameter_name).to_s,
243
+ display_name: field(value, :display_name).to_s,
244
+ description: field(value, :description).to_s,
245
+ scope: normalize_enum(field(value, :scope), prefix: "METRIC_SCOPE_"),
246
+ measurement_unit: normalize_enum(field(value, :measurement_unit), prefix: "MEASUREMENT_UNIT_")
247
+ )
248
+ end
249
+
250
+ def mutation(change, property_id)
251
+ case [change.resource_type, change.operation]
252
+ when %w[data_stream update]
253
+ update_data_stream_request(change.after, property_id)
254
+ when %w[retention update]
255
+ update_retention_request(change.after, property_id)
256
+ when %w[key_event create]
257
+ create_key_event_request(change.after, property_id)
258
+ when %w[custom_dimension create]
259
+ create_custom_dimension_request(change.after, property_id)
260
+ when %w[custom_dimension update]
261
+ update_custom_dimension_request(change.after, property_id)
262
+ when %w[custom_metric create]
263
+ create_custom_metric_request(change.after, property_id)
264
+ when %w[custom_metric update]
265
+ update_custom_metric_request(change.after, property_id)
266
+ else
267
+ raise UnsupportedCapabilityError,
268
+ "Unsupported #{change.operation} operation for #{change.resource_type}"
269
+ end
270
+ end
271
+
272
+ def update_data_stream_request(after, property_id)
273
+ validate_resource_name!(after.fetch("name"), property_id, "dataStreams")
274
+ [
275
+ :update_data_stream,
276
+ {
277
+ data_stream: {
278
+ name: after.fetch("name"),
279
+ web_stream_data: { default_uri: after.fetch("default_uri") }
280
+ },
281
+ update_mask: { paths: ["web_stream_data.default_uri"] }
282
+ }
283
+ ]
284
+ end
285
+
286
+ def update_retention_request(after, property_id)
287
+ expected_name = "#{property_name(property_id)}/dataRetentionSettings"
288
+ unless after.fetch("name") == expected_name
289
+ raise InvalidPlanError,
290
+ "Retention resource belongs to a different property"
291
+ end
292
+
293
+ [
294
+ :update_data_retention_settings,
295
+ {
296
+ data_retention_settings: {
297
+ name: expected_name,
298
+ event_data_retention: google_retention(after.fetch("event_data")),
299
+ user_data_retention: google_retention(after.fetch("user_data")),
300
+ reset_user_data_on_new_activity: after.fetch("reset_on_new_activity")
301
+ },
302
+ update_mask: { paths: %w[event_data_retention user_data_retention reset_user_data_on_new_activity] }
303
+ }
304
+ ]
305
+ end
306
+
307
+ def create_key_event_request(after, property_id)
308
+ [
309
+ :create_key_event,
310
+ {
311
+ parent: property_name(property_id),
312
+ key_event: {
313
+ event_name: after.fetch("event_name"),
314
+ counting_method: key_event_counting_method(after.fetch("counting_method"))
315
+ }
316
+ }
317
+ ]
318
+ end
319
+
320
+ def create_custom_dimension_request(after, property_id)
321
+ [:create_custom_dimension, { parent: property_name(property_id), custom_dimension: dimension_payload(after) }]
322
+ end
323
+
324
+ def update_custom_dimension_request(after, property_id)
325
+ validate_resource_name!(after.fetch("name"), property_id, "customDimensions")
326
+ paths = %w[display_name description]
327
+ paths << "disallow_ads_personalization" if after.fetch("scope") == "user"
328
+ [
329
+ :update_custom_dimension,
330
+ {
331
+ custom_dimension: dimension_payload(after).merge(name: after.fetch("name")),
332
+ update_mask: { paths: }
333
+ }
334
+ ]
335
+ end
336
+
337
+ def dimension_payload(after)
338
+ {
339
+ parameter_name: after.fetch("parameter_name"),
340
+ display_name: after.fetch("display_name"),
341
+ description: after.fetch("description"),
342
+ scope: after.fetch("scope").upcase.to_sym,
343
+ disallow_ads_personalization: after.fetch("disallow_ads_personalization", false)
344
+ }
345
+ end
346
+
347
+ def create_custom_metric_request(after, property_id)
348
+ [:create_custom_metric, { parent: property_name(property_id), custom_metric: metric_payload(after) }]
349
+ end
350
+
351
+ def update_custom_metric_request(after, property_id)
352
+ validate_resource_name!(after.fetch("name"), property_id, "customMetrics")
353
+ [
354
+ :update_custom_metric,
355
+ {
356
+ custom_metric: metric_payload(after).merge(name: after.fetch("name")),
357
+ update_mask: { paths: %w[display_name description] }
358
+ }
359
+ ]
360
+ end
361
+
362
+ def metric_payload(after)
363
+ {
364
+ parameter_name: after.fetch("parameter_name"),
365
+ display_name: after.fetch("display_name"),
366
+ description: after.fetch("description"),
367
+ scope: :EVENT,
368
+ measurement_unit: after.fetch("measurement_unit").upcase.to_sym
369
+ }
370
+ end
371
+
372
+ def validate_resource_name!(name, property_id, collection)
373
+ prefix = "#{property_name(property_id)}/#{collection}/"
374
+ raise InvalidPlanError, "Plan resource belongs to a different property" unless name.start_with?(prefix)
375
+ end
376
+
377
+ def property_name(property_id)
378
+ id = property_id.to_s
379
+ raise ConfigurationError, "Invalid property ID" unless id.match?(/\A\d{1,50}\z/)
380
+
381
+ "properties/#{id}"
382
+ end
383
+
384
+ def google_retention(value)
385
+ RETENTION_TO_GOOGLE.fetch(value) do
386
+ raise InvalidPlanError, "Unsupported retention duration #{value.inspect}"
387
+ end
388
+ end
389
+
390
+ def key_event_counting_method(value)
391
+ {
392
+ "once_per_event" => :ONCE_PER_EVENT,
393
+ "once_per_session" => :ONCE_PER_SESSION
394
+ }.fetch(value) do
395
+ raise InvalidPlanError, "Unsupported key event counting method #{value.inspect}"
396
+ end
397
+ end
398
+
399
+ def normalize_retention_value(value)
400
+ GOOGLE_TO_RETENTION.fetch(enum_symbol(value)) do
401
+ normalize_enum(value, prefix: "RETENTION_DURATION_")
402
+ end
403
+ end
404
+
405
+ def field(value, name)
406
+ return nil if value.nil?
407
+ return value.public_send(name) if value.respond_to?(name)
408
+ return value[name] if value.respond_to?(:key?) && value.key?(name)
409
+ return value[name.to_s] if value.respond_to?(:key?) && value.key?(name.to_s)
410
+
411
+ nil
412
+ end
413
+
414
+ def array_field(value, name)
415
+ Array(field(value, name))
416
+ end
417
+
418
+ def boolean?(value)
419
+ value == true
420
+ end
421
+
422
+ def resource_id(name)
423
+ name.to_s.split("/").last.to_s
424
+ end
425
+
426
+ def optional_string(value)
427
+ string = value.to_s
428
+ string.empty? ? nil : string
429
+ end
430
+
431
+ def normalize_enum(value, prefix: nil)
432
+ name = enum_name(value)
433
+ name = name.delete_prefix(prefix) if prefix
434
+ name.downcase
435
+ end
436
+
437
+ def enum_name(value)
438
+ value.respond_to?(:name) ? value.name.to_s : value.to_s
439
+ end
440
+
441
+ def enum_symbol(value)
442
+ enum_name(value).upcase.to_sym
443
+ end
444
+ end
445
+ end
446
+ end