analytics_ops 0.1.0 → 0.2.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 (56) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +76 -0
  3. data/CONTRIBUTING.md +2 -1
  4. data/README.md +232 -41
  5. data/SECURITY.md +8 -8
  6. data/docs/api-support-matrix.md +9 -8
  7. data/docs/architecture.md +22 -4
  8. data/docs/authentication.md +112 -51
  9. data/docs/commands.md +71 -17
  10. data/docs/configuration-schema-v1.json +53 -7
  11. data/docs/configuration.md +33 -9
  12. data/docs/google-client-compatibility.md +10 -6
  13. data/docs/live-smoke-test.md +159 -0
  14. data/docs/plan-format.md +4 -0
  15. data/docs/plan-schema-v1.json +113 -22
  16. data/docs/rails.md +12 -3
  17. data/docs/reports.md +44 -6
  18. data/docs/safety.md +18 -6
  19. data/docs/troubleshooting.md +42 -13
  20. data/lib/analytics_ops/applier.rb +2 -1
  21. data/lib/analytics_ops/cli/options.rb +157 -0
  22. data/lib/analytics_ops/cli/presenter.rb +221 -0
  23. data/lib/analytics_ops/cli.rb +172 -201
  24. data/lib/analytics_ops/clients/admin.rb +130 -129
  25. data/lib/analytics_ops/clients/admin_normalization.rb +95 -0
  26. data/lib/analytics_ops/clients/data.rb +120 -63
  27. data/lib/analytics_ops/clients/error_translation.rb +45 -0
  28. data/lib/analytics_ops/configuration/document.rb +1 -1
  29. data/lib/analytics_ops/configuration/loader.rb +38 -5
  30. data/lib/analytics_ops/configuration/schema.rb +51 -8
  31. data/lib/analytics_ops/configuration/validator.rb +43 -8
  32. data/lib/analytics_ops/configuration/writer.rb +105 -0
  33. data/lib/analytics_ops/configuration.rb +1 -0
  34. data/lib/analytics_ops/connection.rb +93 -0
  35. data/lib/analytics_ops/desired_state.rb +2 -2
  36. data/lib/analytics_ops/plan.rb +94 -33
  37. data/lib/analytics_ops/planner.rb +10 -2
  38. data/lib/analytics_ops/rails/railtie.rb +13 -18
  39. data/lib/analytics_ops/redaction.rb +14 -9
  40. data/lib/analytics_ops/reports/catalog.rb +22 -5
  41. data/lib/analytics_ops/reports/definition.rb +74 -37
  42. data/lib/analytics_ops/reports/overview.rb +55 -0
  43. data/lib/analytics_ops/reports/overview_result.rb +54 -0
  44. data/lib/analytics_ops/reports/result.rb +38 -9
  45. data/lib/analytics_ops/reports.rb +2 -0
  46. data/lib/analytics_ops/resources.rb +2 -1
  47. data/lib/analytics_ops/service_account.rb +171 -0
  48. data/lib/analytics_ops/setup.rb +171 -0
  49. data/lib/analytics_ops/snapshot.rb +20 -5
  50. data/lib/analytics_ops/version.rb +1 -1
  51. data/lib/analytics_ops/workspace.rb +41 -12
  52. data/lib/analytics_ops.rb +4 -0
  53. data/lib/generators/analytics_ops/install_generator.rb +2 -0
  54. data/lib/generators/analytics_ops/templates/analytics_ops.yml +2 -15
  55. data/sig/analytics_ops.rbs +117 -10
  56. metadata +26 -1
@@ -2,10 +2,14 @@
2
2
 
3
3
  # Official Admin API boundary.
4
4
 
5
+ require_relative "admin_normalization"
6
+
5
7
  module AnalyticsOps
6
8
  module Clients
7
9
  # Narrow adapter around Google's generated Admin client.
8
10
  class Admin
11
+ include AdminNormalization
12
+
9
13
  PACKAGE_REQUIREMENT = Gem::Requirement.new("~> 0.8.0")
10
14
  RETENTION_TO_GOOGLE = {
11
15
  "2_months" => :TWO_MONTHS,
@@ -29,41 +33,53 @@ module AnalyticsOps
29
33
  "custom_metrics" => :create_custom_metric
30
34
  }.freeze
31
35
 
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)
36
+ def initialize(client: nil, service_account: nil, access: :read, transport: :grpc, timeout: nil, logger: nil)
37
+ unless service_account.nil? || service_account.is_a?(ServiceAccount)
38
+ raise ConfigurationError, "service_account must be an AnalyticsOps::ServiceAccount"
39
+ end
40
+ raise ConfigurationError, "access must be :read or :edit" unless %i[read edit].include?(access)
34
41
 
35
42
  @client = client
36
- @credentials = credentials
37
- @transport = transport.to_sym
38
- @timeout = timeout
43
+ @service_account = service_account
44
+ @access = access
45
+ @transport = validate_transport(transport)
46
+ @timeout = validate_timeout(timeout)
39
47
  @logger = logger
40
48
  end
41
49
 
42
- def discover
50
+ def discover(include_streams: true)
51
+ raise ConfigurationError, "include_streams must be true or false" unless [true, false].include?(include_streams)
52
+
43
53
  list(:list_account_summaries, page_size: 200).map do |summary|
54
+ account_name = remote_string(field(summary, :account), "account name")
44
55
  properties = array_field(summary, :property_summaries).map do |property|
45
56
  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)
57
+ next normalized.to_h unless include_streams
58
+
59
+ normalized.to_h.merge("streams" => list_streams(normalized.id).map(&:to_h))
48
60
  end
49
61
  properties.sort_by! { |property| property.fetch("id") }
50
62
 
51
63
  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,
64
+ id: resource_id(account_name),
65
+ name: account_name,
66
+ display_name: remote_string(field(summary, :display_name), "account display name"),
55
67
  properties:
56
68
  )
57
- end.sort_by(&:id)
69
+ end.sort_by(&:id).freeze
58
70
  end
59
71
 
60
72
  def property_access(property_id)
61
- id = property_id.to_s
73
+ expected_name = property_name(property_id)
74
+ id = property_id
62
75
  list(:list_account_summaries, page_size: 200).each do |summary|
63
76
  array_field(summary, :property_summaries).each do |property|
64
77
  next unless resource_id(field(property, :property)) == id
65
78
 
66
- return normalize_property(property, can_edit: field(property, :can_edit))
79
+ normalized = normalize_property(property, can_edit: field(property, :can_edit))
80
+ return normalized if normalized.name == expected_name
81
+
82
+ raise RemoteError, "Google Admin API returned a property name that does not match the request"
67
83
  end
68
84
  end
69
85
 
@@ -72,40 +88,63 @@ module AnalyticsOps
72
88
 
73
89
  def snapshot(property_id)
74
90
  property_name = property_name(property_id)
91
+ property = normalize_property(get(:get_property, name: property_name), can_edit: nil)
92
+ unless property.name == property_name
93
+ raise RemoteError, "Google Admin API returned a property name that does not match the request"
94
+ end
95
+
96
+ streams = list_streams(property_id)
97
+ retention = normalize_retention(get(:get_data_retention_settings,
98
+ name: "#{property_name}/dataRetentionSettings"))
99
+ key_events = list(:list_key_events, parent: property_name, page_size: 200).map do |value|
100
+ normalize_key_event(value)
101
+ end
102
+ custom_dimensions = list(:list_custom_dimensions, parent: property_name, page_size: 200)
103
+ .map { |value| normalize_custom_dimension(value) }
104
+ custom_metrics = list(:list_custom_metrics, parent: property_name, page_size: 200)
105
+ .map { |value| normalize_custom_metric(value) }
106
+ validate_snapshot_names!(
107
+ property: property_name,
108
+ streams:,
109
+ retention:,
110
+ key_events:,
111
+ dimensions: custom_dimensions,
112
+ metrics: custom_metrics
113
+ )
114
+
75
115
  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) }
116
+ property:,
117
+ streams:,
118
+ retention:,
119
+ key_events:,
120
+ custom_dimensions:,
121
+ custom_metrics:
87
122
  )
88
123
  end
89
124
 
90
125
  def capabilities
91
- CAPABILITIES.to_h { |name, method| [name, client.respond_to?(method)] }.freeze
126
+ generated_client = translate_errors { client }
127
+ CAPABILITIES.to_h { |name, method| [name, generated_client.respond_to?(method)] }.freeze
92
128
  end
93
129
 
94
130
  def compatibility
95
131
  specification = Gem::Specification.find_by_name("google-analytics-admin")
96
- {
132
+ Canonical.immutable(
97
133
  "package" => specification.name,
98
134
  "version" => specification.version.to_s,
99
135
  "requirement" => PACKAGE_REQUIREMENT.to_s,
100
136
  "supported" => PACKAGE_REQUIREMENT.satisfied_by?(specification.version),
101
137
  "transport" => @transport.to_s
102
- }.freeze
138
+ )
103
139
  rescue Gem::LoadError => error
104
140
  raise UnsupportedCapabilityError, Redaction.message(error.message)
105
141
  end
106
142
 
107
143
  def apply_change(change, property_id:)
108
- method_name, request = mutation(change, property_id.to_s)
144
+ raise InvalidPlanError, "change must be an AnalyticsOps::Plan::Change" unless change.is_a?(Plan::Change)
145
+
146
+ property_name(property_id)
147
+ method_name, request = mutation(change, property_id)
109
148
  get(method_name, request)
110
149
  change
111
150
  end
@@ -114,9 +153,13 @@ module AnalyticsOps
114
153
 
115
154
  def client
116
155
  @client ||= begin
156
+ unless @service_account
157
+ raise AuthenticationError, "Analytics Ops requires configured service-account credentials"
158
+ end
159
+
117
160
  require "google/analytics/admin"
118
161
  Google::Analytics::Admin.analytics_admin_service(transport: @transport) do |config|
119
- config.credentials = @credentials if @credentials
162
+ config.credentials = @service_account.__send__(:credentials, access: @access)
120
163
  config.timeout = @timeout if @timeout
121
164
  end
122
165
  end
@@ -130,7 +173,7 @@ module AnalyticsOps
130
173
 
131
174
  def list(method_name, request)
132
175
  response = invoke(method_name, request)
133
- response.respond_to?(:to_a) ? response.to_a : Array(response)
176
+ translate_errors { response.respond_to?(:to_a) ? response.to_a : Array(response) }
134
177
  end
135
178
 
136
179
  def get(method_name, request)
@@ -158,93 +201,8 @@ module AnalyticsOps
158
201
  request.dig(:data_retention_settings, :name)
159
202
  end
160
203
 
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
- )
204
+ def translate_errors(&)
205
+ ErrorTranslation.call(&)
248
206
  end
249
207
 
250
208
  def mutation(change, property_id)
@@ -365,20 +323,24 @@ module AnalyticsOps
365
323
  display_name: after.fetch("display_name"),
366
324
  description: after.fetch("description"),
367
325
  scope: :EVENT,
368
- measurement_unit: after.fetch("measurement_unit").upcase.to_sym
326
+ measurement_unit: after.fetch("measurement_unit").upcase.to_sym,
327
+ restricted_metric_type: after.fetch("restricted_metric_types").map { |type| type.upcase.to_sym }
369
328
  }
370
329
  end
371
330
 
372
331
  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)
332
+ pattern = %r{\A#{Regexp.escape(property_name(property_id))}/#{collection}/[A-Za-z0-9_-]+\z}
333
+ return if name.is_a?(String) && pattern.match?(name)
334
+
335
+ raise InvalidPlanError, "Plan resource belongs to a different property"
375
336
  end
376
337
 
377
338
  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/)
339
+ unless property_id.is_a?(String) && property_id.match?(/\A\d{1,50}\z/)
340
+ raise ConfigurationError, "Invalid property ID; expected a numeric string"
341
+ end
380
342
 
381
- "properties/#{id}"
343
+ "properties/#{property_id}"
382
344
  end
383
345
 
384
346
  def google_retention(value)
@@ -415,19 +377,44 @@ module AnalyticsOps
415
377
  Array(field(value, name))
416
378
  end
417
379
 
418
- def boolean?(value)
419
- value == true
380
+ def remote_boolean(value)
381
+ return false if value.nil?
382
+ return value if [true, false].include?(value)
383
+
384
+ raise RemoteError, "Google Admin API returned an invalid boolean"
385
+ end
386
+
387
+ def optional_boolean(value)
388
+ return nil if value.nil?
389
+
390
+ remote_boolean(value)
420
391
  end
421
392
 
422
393
  def resource_id(name)
423
- name.to_s.split("/").last.to_s
394
+ id = remote_string(name, "resource name").split("/").last
395
+ return id unless id.nil? || id.empty?
396
+
397
+ raise RemoteError, "Google Admin API returned an invalid resource name"
424
398
  end
425
399
 
426
- def optional_string(value)
427
- string = value.to_s
400
+ def optional_string(value, label)
401
+ return nil if value.nil?
402
+
403
+ string = remote_string(value, label)
428
404
  string.empty? ? nil : string
429
405
  end
430
406
 
407
+ def remote_string(value, label)
408
+ raise RemoteError, "Google Admin API returned an invalid #{label}" unless value.is_a?(String)
409
+
410
+ string = value.encode(Encoding::UTF_8)
411
+ raise EncodingError unless string.valid_encoding?
412
+
413
+ string
414
+ rescue EncodingError
415
+ raise RemoteError, "Google Admin API returned an invalid #{label}"
416
+ end
417
+
431
418
  def normalize_enum(value, prefix: nil)
432
419
  name = enum_name(value)
433
420
  name = name.delete_prefix(prefix) if prefix
@@ -441,6 +428,20 @@ module AnalyticsOps
441
428
  def enum_symbol(value)
442
429
  enum_name(value).upcase.to_sym
443
430
  end
431
+
432
+ def validate_transport(value)
433
+ transport = value.to_sym if value.respond_to?(:to_sym)
434
+ return transport if %i[grpc rest].include?(transport)
435
+
436
+ raise ConfigurationError, "transport must be grpc or rest"
437
+ end
438
+
439
+ def validate_timeout(value)
440
+ return nil if value.nil?
441
+ return value if [Integer, Float].any? { |type| value.is_a?(type) } && value.finite? && value.positive?
442
+
443
+ raise ConfigurationError, "timeout must be a finite positive number"
444
+ end
444
445
  end
445
446
  end
446
447
  end
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AnalyticsOps
4
+ module Clients
5
+ # Internal normalization of generated Admin API responses into gem-owned values.
6
+ module AdminNormalization
7
+ private
8
+
9
+ def normalize_property(value, can_edit:)
10
+ name = remote_string(field(value, :property) || field(value, :name), "property name")
11
+ Resources::Property.new(
12
+ id: resource_id(name),
13
+ name:,
14
+ display_name: remote_string(field(value, :display_name), "property display name"),
15
+ parent: optional_string(field(value, :parent), "property parent"),
16
+ property_type: normalize_enum(field(value, :property_type), prefix: "PROPERTY_TYPE_"),
17
+ can_edit: optional_boolean(can_edit)
18
+ )
19
+ end
20
+
21
+ def normalize_stream(value)
22
+ name = remote_string(field(value, :name), "data stream name")
23
+ type = Admin::STREAM_TYPES.fetch(enum_name(field(value, :type)), "unspecified")
24
+ web_data = field(value, :web_stream_data)
25
+ Resources::DataStream.new(
26
+ id: resource_id(name),
27
+ name:,
28
+ display_name: remote_string(field(value, :display_name), "data stream display name"),
29
+ type:,
30
+ default_uri: optional_string(field(web_data, :default_uri), "data stream default URI"),
31
+ measurement_id: optional_string(field(web_data, :measurement_id), "measurement ID")
32
+ )
33
+ end
34
+
35
+ def normalize_retention(value)
36
+ Resources::Retention.new(
37
+ name: remote_string(field(value, :name), "retention resource name"),
38
+ event_data: normalize_retention_value(field(value, :event_data_retention)),
39
+ user_data: normalize_retention_value(field(value, :user_data_retention)),
40
+ reset_on_new_activity: remote_boolean(field(value, :reset_user_data_on_new_activity))
41
+ )
42
+ end
43
+
44
+ def normalize_key_event(value)
45
+ Resources::KeyEvent.new(
46
+ name: remote_string(field(value, :name), "key event resource name"),
47
+ event_name: remote_string(field(value, :event_name), "key event name"),
48
+ counting_method: normalize_enum(field(value, :counting_method))
49
+ )
50
+ end
51
+
52
+ def normalize_custom_dimension(value)
53
+ Resources::CustomDimension.new(
54
+ name: remote_string(field(value, :name), "custom dimension resource name"),
55
+ parameter_name: remote_string(field(value, :parameter_name), "custom dimension parameter name"),
56
+ display_name: remote_string(field(value, :display_name), "custom dimension display name"),
57
+ description: remote_string(field(value, :description), "custom dimension description"),
58
+ scope: normalize_enum(field(value, :scope), prefix: "DIMENSION_SCOPE_"),
59
+ disallow_ads_personalization: remote_boolean(field(value, :disallow_ads_personalization))
60
+ )
61
+ end
62
+
63
+ def normalize_custom_metric(value)
64
+ Resources::CustomMetric.new(
65
+ name: remote_string(field(value, :name), "custom metric resource name"),
66
+ parameter_name: remote_string(field(value, :parameter_name), "custom metric parameter name"),
67
+ display_name: remote_string(field(value, :display_name), "custom metric display name"),
68
+ description: remote_string(field(value, :description), "custom metric description"),
69
+ scope: normalize_enum(field(value, :scope), prefix: "METRIC_SCOPE_"),
70
+ measurement_unit: normalize_enum(field(value, :measurement_unit), prefix: "MEASUREMENT_UNIT_"),
71
+ restricted_metric_types: array_field(value, :restricted_metric_type).map do |type|
72
+ normalize_enum(type, prefix: "RESTRICTED_METRIC_TYPE_")
73
+ end.sort
74
+ )
75
+ end
76
+
77
+ def validate_snapshot_names!(property:, streams:, retention:, key_events:, dimensions:, metrics:)
78
+ valid = retention.name == "#{property}/dataRetentionSettings" &&
79
+ resource_collection?(streams, property, "dataStreams") &&
80
+ resource_collection?(key_events, property, "keyEvents") &&
81
+ resource_collection?(dimensions, property, "customDimensions") &&
82
+ resource_collection?(metrics, property, "customMetrics")
83
+ return if valid
84
+
85
+ raise RemoteError, "Google Admin API returned a resource belonging to a different property"
86
+ end
87
+
88
+ def resource_collection?(values, property, collection)
89
+ pattern = %r{\A#{Regexp.escape(property)}/#{collection}/[A-Za-z0-9_-]+\z}
90
+ values.all? { |value| pattern.match?(value.name) }
91
+ end
92
+ end
93
+ private_constant :AdminNormalization
94
+ end
95
+ end