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,344 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Strict configuration validation.
4
+
5
+ require "uri"
6
+
7
+ module AnalyticsOps
8
+ module Configuration
9
+ # Fail-closed validator and normalizer for configuration version 1.
10
+ class Validator
11
+ TOP_LEVEL_KEYS = %w[version profiles].freeze
12
+ PROFILE_KEYS = %w[property_id streams retention google_signals key_events custom_dimensions custom_metrics
13
+ manual_requirements].freeze
14
+ STREAM_KEYS = %w[stream_id default_uri enhanced_measurement].freeze
15
+ ENHANCED_MEASUREMENT_KEYS = %w[enabled experimental].freeze
16
+ RETENTION_KEYS = %w[event_data user_data reset_on_new_activity].freeze
17
+ GOOGLE_SIGNALS_KEYS = %w[state experimental].freeze
18
+ DIMENSION_KEYS = %w[parameter_name display_name description scope disallow_ads_personalization].freeze
19
+ METRIC_KEYS = %w[parameter_name display_name description scope measurement_unit].freeze
20
+ RETENTION_VALUES = %w[2_months 14_months 26_months 38_months 50_months].freeze
21
+ DIMENSION_SCOPES = %w[event user item].freeze
22
+ METRIC_UNITS = %w[standard currency feet meters kilometers miles milliseconds seconds minutes hours].freeze
23
+ SECRET_KEY = /
24
+ (?:\A|_)
25
+ (?:
26
+ credentials?|password|private_key|client_(?:id|secret)|
27
+ access_token|refresh_token|api_key|service_account|oauth
28
+ )
29
+ (?:\z|_)
30
+ /ix
31
+ NAME = /\A[a-z][a-z0-9_]*\z/i
32
+ EVENT_NAME = /\A[a-z][a-z0-9_]{0,39}\z/i
33
+ ID = /\A\d{1,50}\z/
34
+
35
+ def initialize(raw)
36
+ @raw = raw
37
+ end
38
+
39
+ def call
40
+ hash!(@raw, "$root")
41
+ reject_secret_keys!(@raw)
42
+ exact_keys!(@raw, TOP_LEVEL_KEYS, "$root")
43
+ unless @raw["version"] == 1
44
+ raise UnsupportedVersionError, "Unsupported configuration version #{@raw["version"].inspect}; expected 1"
45
+ end
46
+
47
+ profiles = hash!(@raw["profiles"], "profiles")
48
+ raise ConfigurationError, "profiles must contain at least one profile" if profiles.empty?
49
+
50
+ normalized = profiles.to_h do |name, profile|
51
+ validate_name!(name, "profile name")
52
+ [name, validate_profile(name, profile)]
53
+ end
54
+
55
+ Document.new(version: 1, profiles: normalized)
56
+ end
57
+
58
+ private
59
+
60
+ def validate_profile(name, raw)
61
+ path = "profiles.#{name}"
62
+ profile = hash!(raw, path)
63
+ exact_keys!(profile, PROFILE_KEYS, path)
64
+ property_id = id!(required(profile, "property_id", path), "#{path}.property_id")
65
+
66
+ DesiredState.new(
67
+ profile: name,
68
+ property_id:,
69
+ streams: validate_streams(profile.fetch("streams", {}), path),
70
+ retention: validate_retention(profile["retention"], path),
71
+ key_events: validate_key_events(profile.fetch("key_events", []), path),
72
+ custom_dimensions: validate_dimensions(profile.fetch("custom_dimensions", []), path),
73
+ custom_metrics: validate_metrics(profile.fetch("custom_metrics", []), path),
74
+ manual_requirements: validate_manual_requirements(profile.fetch("manual_requirements", []), path),
75
+ google_signals: validate_google_signals(profile["google_signals"], path)
76
+ )
77
+ end
78
+
79
+ def validate_streams(raw, profile_path)
80
+ path = "#{profile_path}.streams"
81
+ streams = hash!(raw, path).map do |name, value|
82
+ validate_name!(name, "#{path} key")
83
+ stream_path = "#{path}.#{name}"
84
+ stream = hash!(value, stream_path)
85
+ exact_keys!(stream, STREAM_KEYS, stream_path)
86
+
87
+ {
88
+ "name" => name,
89
+ "stream_id" => id!(required(stream, "stream_id", stream_path), "#{stream_path}.stream_id"),
90
+ "default_uri" => validate_uri(stream["default_uri"], "#{stream_path}.default_uri"),
91
+ "enhanced_measurement" => validate_enhanced_measurement(stream["enhanced_measurement"], stream_path)
92
+ }
93
+ end
94
+ streams.sort_by { |stream| stream.fetch("name") }
95
+ end
96
+
97
+ def validate_enhanced_measurement(raw, stream_path)
98
+ return nil if raw.nil?
99
+
100
+ path = "#{stream_path}.enhanced_measurement"
101
+ settings = hash!(raw, path)
102
+ exact_keys!(settings, ENHANCED_MEASUREMENT_KEYS, path)
103
+ enabled = boolean!(required(settings, "enabled", path), "#{path}.enabled")
104
+ experimental = boolean!(required(settings, "experimental", path), "#{path}.experimental")
105
+ raise ConfigurationError, "#{path}.experimental must be true" unless experimental
106
+
107
+ { "enabled" => enabled, "experimental" => true }
108
+ end
109
+
110
+ def validate_retention(raw, profile_path)
111
+ return nil if raw.nil?
112
+
113
+ path = "#{profile_path}.retention"
114
+ retention = hash!(raw, path)
115
+ exact_keys!(retention, RETENTION_KEYS, path)
116
+ event_data = enum!(required(retention, "event_data", path), RETENTION_VALUES, "#{path}.event_data")
117
+ user_data = enum!(required(retention, "user_data", path), RETENTION_VALUES, "#{path}.user_data")
118
+ reset = boolean!(required(retention, "reset_on_new_activity", path), "#{path}.reset_on_new_activity")
119
+
120
+ { "event_data" => event_data, "user_data" => user_data, "reset_on_new_activity" => reset }
121
+ end
122
+
123
+ def validate_google_signals(raw, profile_path)
124
+ return nil if raw.nil?
125
+
126
+ path = "#{profile_path}.google_signals"
127
+ settings = hash!(raw, path)
128
+ exact_keys!(settings, GOOGLE_SIGNALS_KEYS, path)
129
+ state = enum!(required(settings, "state", path), %w[enabled disabled], "#{path}.state")
130
+ experimental = boolean!(required(settings, "experimental", path), "#{path}.experimental")
131
+ raise ConfigurationError, "#{path}.experimental must be true" unless experimental
132
+
133
+ { "state" => state, "experimental" => true }
134
+ end
135
+
136
+ def validate_key_events(raw, profile_path)
137
+ path = "#{profile_path}.key_events"
138
+ values = array!(raw, path).map.with_index do |event_name, index|
139
+ string = string!(event_name, "#{profile_path}.key_events[#{index}]")
140
+ unless EVENT_NAME.match?(string)
141
+ raise ConfigurationError, "#{profile_path}.key_events[#{index}] is not a valid GA4 event name"
142
+ end
143
+
144
+ string
145
+ end
146
+
147
+ ensure_unique_strings!(values, path)
148
+ end
149
+
150
+ def validate_dimensions(raw, profile_path)
151
+ path = "#{profile_path}.custom_dimensions"
152
+ values = array!(raw, path).map.with_index do |value, index|
153
+ item_path = "#{path}[#{index}]"
154
+ dimension = hash!(value, item_path)
155
+ exact_keys!(dimension, DIMENSION_KEYS, item_path)
156
+ scope = enum!(required(dimension, "scope", item_path), DIMENSION_SCOPES, "#{item_path}.scope")
157
+ parameter = parameter_name!(required(dimension, "parameter_name", item_path), scope, item_path)
158
+
159
+ {
160
+ "parameter_name" => parameter,
161
+ "display_name" => display_name!(required(dimension, "display_name", item_path), item_path),
162
+ "description" => description!(dimension.fetch("description", ""), item_path),
163
+ "scope" => scope,
164
+ "disallow_ads_personalization" => boolean!(dimension.fetch("disallow_ads_personalization", false),
165
+ "#{item_path}.disallow_ads_personalization")
166
+ }.tap do |normalized|
167
+ if normalized.fetch("disallow_ads_personalization") && scope != "user"
168
+ raise ConfigurationError, "#{item_path}.disallow_ads_personalization is valid only for user scope"
169
+ end
170
+ end
171
+ end
172
+
173
+ ensure_unique!(values, %w[scope parameter_name], path)
174
+ end
175
+
176
+ def validate_metrics(raw, profile_path)
177
+ path = "#{profile_path}.custom_metrics"
178
+ values = array!(raw, path).map.with_index do |value, index|
179
+ item_path = "#{path}[#{index}]"
180
+ metric = hash!(value, item_path)
181
+ exact_keys!(metric, METRIC_KEYS, item_path)
182
+ scope = enum!(required(metric, "scope", item_path), ["event"], "#{item_path}.scope")
183
+
184
+ {
185
+ "parameter_name" => parameter_name!(required(metric, "parameter_name", item_path), scope, item_path),
186
+ "display_name" => display_name!(required(metric, "display_name", item_path), item_path),
187
+ "description" => description!(metric.fetch("description", ""), item_path),
188
+ "scope" => scope,
189
+ "measurement_unit" => enum!(metric.fetch("measurement_unit", "standard"), METRIC_UNITS,
190
+ "#{item_path}.measurement_unit")
191
+ }
192
+ end
193
+
194
+ ensure_unique!(values, ["parameter_name"], path)
195
+ end
196
+
197
+ def validate_manual_requirements(raw, profile_path)
198
+ path = "#{profile_path}.manual_requirements"
199
+ values = array!(raw, path).map.with_index do |value, index|
200
+ requirement = string!(value, "#{path}[#{index}]")
201
+ validate_name!(requirement, "#{path}[#{index}]")
202
+ requirement
203
+ end
204
+
205
+ ensure_unique_strings!(values, path)
206
+ end
207
+
208
+ def parameter_name!(value, scope, path)
209
+ parameter = string!(value, "#{path}.parameter_name")
210
+ maximum = scope == "user" ? 24 : 40
211
+ unless NAME.match?(parameter) && parameter.length <= maximum
212
+ raise ConfigurationError,
213
+ "#{path}.parameter_name must start with a letter and be at most #{maximum} characters"
214
+ end
215
+
216
+ parameter
217
+ end
218
+
219
+ def display_name!(value, path)
220
+ name = string!(value, "#{path}.display_name")
221
+ if name.empty? || name.length > 82 || name.match?(/[\u0000-\u001f]/)
222
+ raise ConfigurationError, "#{path}.display_name must contain 1 to 82 printable characters"
223
+ end
224
+
225
+ name
226
+ end
227
+
228
+ def description!(value, path)
229
+ description = string!(value, "#{path}.description")
230
+ if description.length > 150 || description.match?(/[\u0000-\u001f\u007f]/)
231
+ raise ConfigurationError, "#{path}.description must be printable and at most 150 characters"
232
+ end
233
+
234
+ description
235
+ end
236
+
237
+ def validate_uri(value, path)
238
+ return nil if value.nil?
239
+
240
+ string = string!(value, path)
241
+ uri = URI.parse(string)
242
+ unless %w[http https].include?(uri.scheme) && uri.host && !uri.userinfo
243
+ raise ConfigurationError, "#{path} must be an absolute HTTP or HTTPS URI without credentials"
244
+ end
245
+
246
+ string
247
+ rescue URI::InvalidURIError
248
+ raise ConfigurationError, "#{path} must be a valid URI"
249
+ end
250
+
251
+ def reject_secret_keys!(value, path = "$root")
252
+ case value
253
+ when Hash
254
+ value.each do |key, child|
255
+ raise ConfigurationError, "Secret-shaped key #{path}.#{key} is forbidden" if key.to_s.match?(SECRET_KEY)
256
+
257
+ reject_secret_keys!(child, "#{path}.#{key}")
258
+ end
259
+ when Array
260
+ value.each_with_index { |child, index| reject_secret_keys!(child, "#{path}[#{index}]") }
261
+ end
262
+ end
263
+
264
+ def exact_keys!(hash, allowed, path)
265
+ keys = hash.keys
266
+ non_strings = keys.grep_v(String)
267
+ raise ConfigurationError, "#{path} keys must be strings" unless non_strings.empty?
268
+
269
+ unknown = keys - allowed
270
+ raise ConfigurationError, "Unknown configuration key #{path}.#{unknown.first}" unless unknown.empty?
271
+ end
272
+
273
+ def ensure_unique!(values, keys, path)
274
+ grouped = values.group_by do |value|
275
+ keys.map do |key|
276
+ value.fetch(key)
277
+ end
278
+ end
279
+ duplicate = grouped.find { |_identity, rows| rows.length > 1 }
280
+ raise ConfigurationError, "Duplicate identity in #{path}: #{duplicate.first.join(":")}" if duplicate
281
+
282
+ values.sort_by { |value| keys.map { |key| value.fetch(key) } }
283
+ end
284
+
285
+ def ensure_unique_strings!(values, path)
286
+ duplicate = values.tally.find { |_value, count| count > 1 }&.first
287
+ raise ConfigurationError, "Duplicate value in #{path}: #{duplicate}" if duplicate
288
+
289
+ values.sort
290
+ end
291
+
292
+ def required(hash, key, path)
293
+ return hash[key] if hash.key?(key)
294
+
295
+ raise ConfigurationError, "Missing required configuration key #{path}.#{key}"
296
+ end
297
+
298
+ def hash!(value, path)
299
+ return value if value.is_a?(Hash)
300
+
301
+ raise ConfigurationError, "#{path} must be a mapping"
302
+ end
303
+
304
+ def array!(value, path)
305
+ return value if value.is_a?(Array)
306
+
307
+ raise ConfigurationError, "#{path} must be an array"
308
+ end
309
+
310
+ def string!(value, path)
311
+ return value if value.is_a?(String)
312
+
313
+ raise ConfigurationError, "#{path} must be a string"
314
+ end
315
+
316
+ def boolean!(value, path)
317
+ return value if [true, false].include?(value)
318
+
319
+ raise ConfigurationError, "#{path} must be true or false"
320
+ end
321
+
322
+ def id!(value, path)
323
+ string = string!(value, path)
324
+ raise ConfigurationError, "#{path} must be a numeric identifier encoded as a string" unless ID.match?(string)
325
+
326
+ string
327
+ end
328
+
329
+ def enum!(value, allowed, path)
330
+ string = string!(value, path)
331
+ return string if allowed.include?(string)
332
+
333
+ raise ConfigurationError, "#{path} must be one of: #{allowed.join(", ")}"
334
+ end
335
+
336
+ def validate_name!(value, path)
337
+ string = string!(value, path)
338
+ return string if NAME.match?(string) && string.length <= 64
339
+
340
+ raise ConfigurationError, "#{path} must use letters, numbers, and underscores and start with a letter"
341
+ end
342
+ end
343
+ end
344
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Safe configuration entry point.
4
+
5
+ require_relative "configuration/document"
6
+ require_relative "configuration/loader"
7
+ require_relative "configuration/validator"
8
+ require_relative "configuration/schema"
9
+
10
+ module AnalyticsOps
11
+ # Strict versioned desired-state loading.
12
+ module Configuration
13
+ module_function
14
+
15
+ def load(path, environment: ENV)
16
+ Loader.new(environment:).load(path)
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Validated configuration state.
4
+
5
+ module AnalyticsOps
6
+ # Validated, immutable desired state for one named profile.
7
+ class DesiredState
8
+ attr_reader :profile, :property_id, :streams, :retention, :key_events,
9
+ :custom_dimensions, :custom_metrics, :manual_requirements,
10
+ :google_signals
11
+
12
+ def initialize(profile:, property_id:, streams:, retention:, key_events:, custom_dimensions:, custom_metrics:,
13
+ manual_requirements:, google_signals:)
14
+ @profile = profile.freeze
15
+ @property_id = property_id.freeze
16
+ @streams = Canonical.immutable(streams)
17
+ @retention = Canonical.immutable(retention)
18
+ @key_events = Canonical.immutable(key_events)
19
+ @custom_dimensions = Canonical.immutable(custom_dimensions)
20
+ @custom_metrics = Canonical.immutable(custom_metrics)
21
+ @manual_requirements = Canonical.immutable(manual_requirements)
22
+ @google_signals = Canonical.immutable(google_signals)
23
+ freeze
24
+ end
25
+
26
+ def to_h
27
+ {
28
+ "profile" => profile,
29
+ "property_id" => property_id,
30
+ "streams" => streams,
31
+ "retention" => retention,
32
+ "key_events" => key_events,
33
+ "custom_dimensions" => custom_dimensions,
34
+ "custom_metrics" => custom_metrics,
35
+ "manual_requirements" => manual_requirements,
36
+ "google_signals" => google_signals
37
+ }
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Public typed errors.
4
+
5
+ module AnalyticsOps
6
+ class Error < StandardError; end
7
+ class ConfigurationError < Error; end
8
+ class EnvironmentVariableError < ConfigurationError; end
9
+ class UnsupportedVersionError < ConfigurationError; end
10
+ class AuthenticationError < Error; end
11
+ class AuthorizationError < Error; end
12
+ class UnsupportedCapabilityError < Error; end
13
+ class ConflictError < Error; end
14
+ class InvalidPlanError < Error; end
15
+ class StalePlanError < Error; end
16
+ class ConfirmationRequiredError < Error; end
17
+ class QuotaError < Error; end
18
+ class TimeoutError < Error; end
19
+ class InvalidRequestError < Error; end
20
+ class RemoteError < Error; end
21
+
22
+ # Apply failure carrying structured successful, failed, and remaining changes.
23
+ class PartialApplyError < Error
24
+ attr_reader :result
25
+
26
+ def initialize(message, result:)
27
+ @result = result
28
+ super(message)
29
+ end
30
+ end
31
+ end