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,314 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AnalyticsOps
4
+ module Clients
5
+ # Narrow adapter around Google's generated Data API client.
6
+ class Data
7
+ PACKAGE_REQUIREMENT = Gem::Requirement.new("~> 0.9.0")
8
+ MATCH_TYPES = {
9
+ "exact" => :EXACT,
10
+ "begins_with" => :BEGINS_WITH,
11
+ "ends_with" => :ENDS_WITH,
12
+ "contains" => :CONTAINS,
13
+ "full_regexp" => :FULL_REGEXP,
14
+ "partial_regexp" => :PARTIAL_REGEXP
15
+ }.freeze
16
+ NUMERIC_OPERATIONS = {
17
+ "equal" => :EQUAL,
18
+ "less_than" => :LESS_THAN,
19
+ "less_than_or_equal" => :LESS_THAN_OR_EQUAL,
20
+ "greater_than" => :GREATER_THAN,
21
+ "greater_than_or_equal" => :GREATER_THAN_OR_EQUAL
22
+ }.freeze
23
+
24
+ QUOTA_FIELDS = %i[
25
+ tokens_per_day
26
+ tokens_per_hour
27
+ tokens_per_project_per_hour
28
+ concurrent_requests
29
+ server_errors_per_project_per_hour
30
+ potentially_thresholded_requests_per_hour
31
+ ].freeze
32
+
33
+ def initialize(client: nil, credentials: nil, transport: :grpc, timeout: nil, logger: nil)
34
+ transport = transport.to_sym
35
+ raise ConfigurationError, "transport must be grpc or rest" unless %i[grpc rest].include?(transport)
36
+
37
+ @client = client
38
+ @credentials = credentials
39
+ @transport = transport
40
+ @timeout = timeout
41
+ @logger = logger
42
+ end
43
+
44
+ def run(property_id, definition)
45
+ unless definition.is_a?(Reports::Definition)
46
+ raise InvalidRequestError, "report must be an AnalyticsOps::Reports::Definition"
47
+ end
48
+
49
+ response = if definition.realtime?
50
+ invoke(:run_realtime_report, realtime_request(property_id, definition))
51
+ else
52
+ invoke(:run_report, standard_request(property_id, definition))
53
+ end
54
+
55
+ normalize_result(definition, response)
56
+ end
57
+
58
+ def available?
59
+ generated_client = translate_errors { client }
60
+ generated_client.respond_to?(:run_report) && generated_client.respond_to?(:run_realtime_report)
61
+ end
62
+
63
+ def compatibility
64
+ specification = Gem::Specification.find_by_name("google-analytics-data")
65
+ {
66
+ "package" => specification.name,
67
+ "version" => specification.version.to_s,
68
+ "requirement" => PACKAGE_REQUIREMENT.to_s,
69
+ "supported" => PACKAGE_REQUIREMENT.satisfied_by?(specification.version),
70
+ "transport" => @transport.to_s
71
+ }.freeze
72
+ rescue Gem::LoadError => error
73
+ raise UnsupportedCapabilityError, Redaction.message(error.message)
74
+ end
75
+
76
+ private
77
+
78
+ def client
79
+ @client ||= begin
80
+ require "google/analytics/data"
81
+ Google::Analytics::Data.analytics_data(transport: @transport) do |config|
82
+ config.credentials = @credentials if @credentials
83
+ config.timeout = @timeout if @timeout
84
+ end
85
+ end
86
+ end
87
+
88
+ def standard_request(property_id, definition)
89
+ common_request(property_id, definition).merge(
90
+ offset: definition.offset,
91
+ date_ranges: definition.date_ranges.map do |range|
92
+ {
93
+ start_date: range.fetch("start_date"),
94
+ end_date: range.fetch("end_date"),
95
+ name: range["name"]
96
+ }.compact
97
+ end
98
+ )
99
+ end
100
+
101
+ def realtime_request(property_id, definition)
102
+ common_request(property_id, definition)
103
+ end
104
+
105
+ def common_request(property_id, definition)
106
+ {
107
+ property: property_name(property_id),
108
+ dimensions: definition.dimensions.map { |name| { name: } },
109
+ metrics: definition.metrics.map { |name| { name: } },
110
+ dimension_filter: filter_request(definition.dimension_filter),
111
+ metric_filter: metric_filter_request(definition.metric_filter),
112
+ order_bys: definition.order_bys.map { |order| order_request(order) },
113
+ limit: definition.limit,
114
+ return_property_quota: true
115
+ }.compact
116
+ end
117
+
118
+ def filter_request(filter)
119
+ return nil unless filter
120
+
121
+ {
122
+ filter: {
123
+ field_name: filter.fetch("field"),
124
+ string_filter: {
125
+ match_type: MATCH_TYPES.fetch(filter.fetch("match_type")),
126
+ value: filter.fetch("value"),
127
+ case_sensitive: filter.fetch("case_sensitive")
128
+ }
129
+ }
130
+ }
131
+ end
132
+
133
+ def metric_filter_request(filter)
134
+ return nil unless filter
135
+
136
+ number = filter.fetch("value")
137
+ numeric_value = if number.is_a?(Integer)
138
+ { int64_value: number }
139
+ else
140
+ { double_value: number }
141
+ end
142
+ {
143
+ filter: {
144
+ field_name: filter.fetch("field"),
145
+ numeric_filter: {
146
+ operation: NUMERIC_OPERATIONS.fetch(filter.fetch("operation")),
147
+ value: numeric_value
148
+ }
149
+ }
150
+ }
151
+ end
152
+
153
+ def order_request(order)
154
+ selector = order.key?("metric") ? :metric : :dimension
155
+ name_key = selector == :metric ? :metric_name : :dimension_name
156
+ {
157
+ selector => { name_key => order.fetch(selector.to_s) },
158
+ desc: order.fetch("desc")
159
+ }
160
+ end
161
+
162
+ def invoke(method_name, request)
163
+ SafeLogging.write(
164
+ @logger,
165
+ :info,
166
+ "google_data_request",
167
+ "method" => method_name.to_s,
168
+ "property" => request.fetch(:property)
169
+ )
170
+ generated_client = translate_errors { client }
171
+ unless generated_client.respond_to?(method_name)
172
+ raise UnsupportedCapabilityError, "Installed Google Data client does not support #{method_name}"
173
+ end
174
+
175
+ translate_errors { generated_client.public_send(method_name, request) }
176
+ end
177
+
178
+ def translate_errors
179
+ yield
180
+ rescue AnalyticsOps::Error
181
+ raise
182
+ rescue StandardError => error
183
+ translated = case error.class.name
184
+ when /Unauthenticated|Google::Auth|Signet::Authorization/
185
+ AuthenticationError
186
+ when /PermissionDenied|Forbidden/
187
+ AuthorizationError
188
+ when /ResourceExhausted|TooManyRequests/
189
+ QuotaError
190
+ when /DeadlineExceeded|Timeout|ETIMEDOUT/
191
+ TimeoutError
192
+ when /InvalidArgument|FailedPrecondition|NotFound/
193
+ InvalidRequestError
194
+ when /Google::Cloud::|GRPC::|Faraday::|HTTP/
195
+ RemoteError
196
+ end
197
+ raise unless translated
198
+
199
+ raise translated, Redaction.message(error.message)
200
+ end
201
+
202
+ def normalize_result(definition, response)
203
+ dimension_headers = array_field(response, :dimension_headers).map { |header| field(header, :name).to_s }
204
+ metric_headers = array_field(response, :metric_headers).map { |header| field(header, :name).to_s }
205
+ unless dimension_headers == definition.dimensions && metric_headers == definition.metrics
206
+ raise RemoteError, "Google Data API returned headers that do not match the requested report"
207
+ end
208
+
209
+ rows = array_field(response, :rows).map do |row|
210
+ dimensions = values(row, :dimension_values)
211
+ metrics = values(row, :metric_values)
212
+ unless dimensions.length == dimension_headers.length && metrics.length == metric_headers.length
213
+ raise RemoteError, "Google Data API returned a row that does not match its headers"
214
+ end
215
+
216
+ dimension_headers.zip(dimensions).to_h.merge(metric_headers.zip(metrics).to_h)
217
+ end
218
+
219
+ Reports::Result.new(
220
+ name: definition.name,
221
+ kind: definition.kind,
222
+ dimension_headers:,
223
+ metric_headers:,
224
+ rows:,
225
+ row_count: response_row_count(response, rows.length),
226
+ metadata: metadata(response)
227
+ )
228
+ end
229
+
230
+ def values(row, name)
231
+ array_field(row, name).map { |value| field(value, :value).to_s }
232
+ end
233
+
234
+ def metadata(response)
235
+ response_metadata = field(response, :metadata)
236
+ result = {
237
+ "data_loss_from_other_row" => boolean?(field(response_metadata, :data_loss_from_other_row)),
238
+ "subject_to_thresholding" => boolean?(field(response_metadata, :subject_to_thresholding)),
239
+ "currency_code" => optional_string(field(response_metadata, :currency_code)),
240
+ "time_zone" => optional_string(field(response_metadata, :time_zone)),
241
+ "empty_reason" => optional_string(field(response_metadata, :empty_reason)),
242
+ "sampling" => sampling(response_metadata),
243
+ "property_quota" => quota(field(response, :property_quota))
244
+ }
245
+ result.reject { |_key, value| value.nil? || value == [] }
246
+ end
247
+
248
+ def sampling(response_metadata)
249
+ array_field(response_metadata, :sampling_metadatas).map do |sample|
250
+ {
251
+ "samples_read_count" => integer(field(sample, :samples_read_count), 0),
252
+ "sampling_space_size" => integer(field(sample, :sampling_space_size), 0)
253
+ }
254
+ end
255
+ end
256
+
257
+ def quota(value)
258
+ return nil unless value
259
+
260
+ QUOTA_FIELDS.each_with_object({}) do |name, result|
261
+ status = field(value, name)
262
+ next unless status
263
+
264
+ result[name.to_s] = {
265
+ "consumed" => integer(field(status, :consumed), 0),
266
+ "remaining" => integer(field(status, :remaining), 0)
267
+ }
268
+ end
269
+ end
270
+
271
+ def property_name(property_id)
272
+ id = property_id.to_s
273
+ raise ConfigurationError, "Invalid property ID" unless id.match?(/\A\d{1,50}\z/)
274
+
275
+ "properties/#{id}"
276
+ end
277
+
278
+ def field(value, name)
279
+ return nil if value.nil?
280
+ return value.public_send(name) if value.respond_to?(name)
281
+ return value[name] if value.respond_to?(:key?) && value.key?(name)
282
+ return value[name.to_s] if value.respond_to?(:key?) && value.key?(name.to_s)
283
+
284
+ nil
285
+ end
286
+
287
+ def array_field(value, name)
288
+ Array(field(value, name))
289
+ end
290
+
291
+ def optional_string(value)
292
+ string = value.to_s
293
+ string.empty? ? nil : string
294
+ end
295
+
296
+ def integer(value, default)
297
+ value.nil? ? default : Integer(value)
298
+ rescue ArgumentError, TypeError
299
+ default
300
+ end
301
+
302
+ def response_row_count(response, default)
303
+ value = field(response, :row_count)
304
+ value.nil? ? default : Integer(value)
305
+ rescue ArgumentError, TypeError
306
+ raise RemoteError, "Google Data API returned an invalid row count"
307
+ end
308
+
309
+ def boolean?(value)
310
+ value == true
311
+ end
312
+ end
313
+ end
314
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Immutable configuration document.
4
+
5
+ module AnalyticsOps
6
+ module Configuration
7
+ # Immutable collection of validated named profiles.
8
+ class Document
9
+ attr_reader :version, :profiles
10
+
11
+ def initialize(version:, profiles:)
12
+ @version = version
13
+ @profiles = Canonical.deep_freeze(profiles)
14
+ freeze
15
+ end
16
+
17
+ def profile(name)
18
+ profiles.fetch(name.to_s) do
19
+ available = profiles.keys.sort.join(", ")
20
+ raise ConfigurationError, "Unknown profile #{name.inspect}; available profiles: #{available}"
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Safe YAML loading implementation.
4
+
5
+ require "psych"
6
+
7
+ module AnalyticsOps
8
+ module Configuration
9
+ # Bounded safe-YAML reader with allowlisted environment interpolation.
10
+ class Loader
11
+ MAX_BYTES = 1_048_576
12
+ VARIABLE = /\$\{([A-Z][A-Z0-9_]*)\}/
13
+
14
+ def initialize(environment: ENV)
15
+ @environment = environment
16
+ end
17
+
18
+ def load(path)
19
+ source = read(path)
20
+ raise ConfigurationError, "ERB is not allowed in Analytics Ops configuration" if source.include?("<%")
21
+
22
+ parsed = Psych.safe_load(
23
+ source,
24
+ permitted_classes: [],
25
+ permitted_symbols: [],
26
+ aliases: false,
27
+ filename: path.to_s,
28
+ fallback: {}
29
+ )
30
+
31
+ Validator.new(interpolate(parsed)).call
32
+ rescue Psych::Exception => error
33
+ raise ConfigurationError, "Invalid YAML in #{path}: #{error.message}"
34
+ end
35
+
36
+ private
37
+
38
+ def read(path)
39
+ contents = File.binread(path, MAX_BYTES + 1)
40
+ raise ConfigurationError, "Configuration exceeds #{MAX_BYTES} bytes" if contents.bytesize > MAX_BYTES
41
+
42
+ contents
43
+ rescue SystemCallError => error
44
+ raise ConfigurationError, "Cannot read configuration #{path}: #{error.message}"
45
+ end
46
+
47
+ def interpolate(value)
48
+ case value
49
+ when Hash
50
+ value.to_h { |key, child| [key, interpolate(child)] }
51
+ when Array
52
+ value.map { |child| interpolate(child) }
53
+ when String
54
+ interpolate_string(value)
55
+ else
56
+ value
57
+ end
58
+ end
59
+
60
+ def interpolate_string(value)
61
+ result = value.gsub(VARIABLE) do
62
+ name = Regexp.last_match(1)
63
+ raise EnvironmentVariableError, "Missing environment variable #{name}" unless @environment.key?(name)
64
+
65
+ @environment.fetch(name).to_s
66
+ end
67
+
68
+ if result.include?("${")
69
+ raise EnvironmentVariableError, "Malformed environment interpolation in #{value.inspect}"
70
+ end
71
+
72
+ result
73
+ end
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AnalyticsOps
4
+ module Configuration
5
+ # Public machine-readable summary. Runtime validation also enforces
6
+ # cross-field identity rules and printable user-visible values.
7
+ SCHEMA = Canonical.deep_freeze(
8
+ "$schema" => "https://json-schema.org/draft/2020-12/schema",
9
+ "$id" => "https://github.com/nelsonchaves/analytics_ops/blob/master/docs/configuration-schema-v1.json",
10
+ "title" => "Analytics Ops configuration version 1",
11
+ "type" => "object",
12
+ "additionalProperties" => false,
13
+ "required" => %w[version profiles],
14
+ "properties" => {
15
+ "version" => { "const" => 1 },
16
+ "profiles" => {
17
+ "type" => "object",
18
+ "minProperties" => 1,
19
+ "propertyNames" => { "pattern" => "^[A-Za-z][A-Za-z0-9_]{0,63}$" },
20
+ "additionalProperties" => { "$ref" => "#/$defs/profile" }
21
+ }
22
+ },
23
+ "$defs" => {
24
+ "id" => {
25
+ "type" => "string",
26
+ "pattern" => "^(?:[0-9]{1,50}|\\$\\{[A-Z][A-Z0-9_]*\\})$"
27
+ },
28
+ "profile" => {
29
+ "type" => "object",
30
+ "additionalProperties" => false,
31
+ "required" => ["property_id"],
32
+ "properties" => {
33
+ "property_id" => { "$ref" => "#/$defs/id" },
34
+ "streams" => {
35
+ "type" => "object",
36
+ "propertyNames" => { "pattern" => "^[A-Za-z][A-Za-z0-9_]{0,63}$" },
37
+ "additionalProperties" => { "$ref" => "#/$defs/stream" }
38
+ },
39
+ "retention" => { "$ref" => "#/$defs/retention" },
40
+ "google_signals" => { "$ref" => "#/$defs/googleSignals" },
41
+ "key_events" => {
42
+ "type" => "array", "uniqueItems" => true,
43
+ "items" => { "type" => "string", "pattern" => "^[A-Za-z][A-Za-z0-9_]{0,39}$" }
44
+ },
45
+ "custom_dimensions" => { "type" => "array", "items" => { "$ref" => "#/$defs/customDimension" } },
46
+ "custom_metrics" => { "type" => "array", "items" => { "$ref" => "#/$defs/customMetric" } },
47
+ "manual_requirements" => {
48
+ "type" => "array", "uniqueItems" => true,
49
+ "items" => { "type" => "string", "pattern" => "^[A-Za-z][A-Za-z0-9_]{0,63}$" }
50
+ }
51
+ }
52
+ },
53
+ "stream" => {
54
+ "type" => "object",
55
+ "additionalProperties" => false,
56
+ "required" => ["stream_id"],
57
+ "properties" => {
58
+ "stream_id" => { "$ref" => "#/$defs/id" },
59
+ "default_uri" => { "type" => "string", "maxLength" => 2_048, "pattern" => "^(?:https?://|\\$\\{)" },
60
+ "enhanced_measurement" => {
61
+ "type" => "object", "additionalProperties" => false,
62
+ "required" => %w[enabled experimental],
63
+ "properties" => { "enabled" => { "type" => "boolean" }, "experimental" => { "const" => true } }
64
+ }
65
+ }
66
+ },
67
+ "retention" => {
68
+ "type" => "object", "additionalProperties" => false,
69
+ "required" => %w[event_data user_data reset_on_new_activity],
70
+ "properties" => {
71
+ "event_data" => { "$ref" => "#/$defs/retentionValue" },
72
+ "user_data" => { "$ref" => "#/$defs/retentionValue" },
73
+ "reset_on_new_activity" => { "type" => "boolean" }
74
+ }
75
+ },
76
+ "retentionValue" => { "enum" => Configuration::Validator::RETENTION_VALUES },
77
+ "googleSignals" => {
78
+ "type" => "object", "additionalProperties" => false,
79
+ "required" => %w[state experimental],
80
+ "properties" => { "state" => { "enum" => %w[enabled disabled] }, "experimental" => { "const" => true } }
81
+ },
82
+ "customDimension" => {
83
+ "type" => "object", "additionalProperties" => false,
84
+ "required" => %w[parameter_name display_name scope],
85
+ "properties" => {
86
+ "parameter_name" => { "type" => "string", "pattern" => "^[A-Za-z][A-Za-z0-9_]{0,39}$" },
87
+ "display_name" => { "type" => "string", "minLength" => 1, "maxLength" => 82 },
88
+ "description" => { "type" => "string", "maxLength" => 150 },
89
+ "scope" => { "enum" => Configuration::Validator::DIMENSION_SCOPES },
90
+ "disallow_ads_personalization" => { "type" => "boolean" }
91
+ }
92
+ },
93
+ "customMetric" => {
94
+ "type" => "object", "additionalProperties" => false,
95
+ "required" => %w[parameter_name display_name scope],
96
+ "properties" => {
97
+ "parameter_name" => { "type" => "string", "pattern" => "^[A-Za-z][A-Za-z0-9_]{0,39}$" },
98
+ "display_name" => { "type" => "string", "minLength" => 1, "maxLength" => 82 },
99
+ "description" => { "type" => "string", "maxLength" => 150 },
100
+ "scope" => { "const" => "event" },
101
+ "measurement_unit" => { "enum" => Configuration::Validator::METRIC_UNITS }
102
+ }
103
+ }
104
+ }
105
+ )
106
+ end
107
+ end