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,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AnalyticsOps
4
+ module Reports
5
+ # Immutable normalized Data API response.
6
+ class Result
7
+ attr_reader :name, :kind, :dimension_headers, :metric_headers, :rows, :row_count, :metadata
8
+
9
+ def initialize(name:, kind:, dimension_headers:, metric_headers:, rows:, row_count:, metadata:)
10
+ @name = string(name, "name")
11
+ @kind = kind_value(kind)
12
+ @dimension_headers = normalize_headers(dimension_headers, "dimension_headers")
13
+ @metric_headers = normalize_headers(metric_headers, "metric_headers")
14
+ validate_header_uniqueness!
15
+ @rows = normalized_rows(rows)
16
+ @row_count = count(row_count)
17
+ @metadata = hash(metadata, "metadata")
18
+ freeze
19
+ end
20
+
21
+ def headers
22
+ dimension_headers + metric_headers
23
+ end
24
+
25
+ def to_h
26
+ {
27
+ "name" => name,
28
+ "kind" => kind,
29
+ "dimension_headers" => dimension_headers,
30
+ "metric_headers" => metric_headers,
31
+ "rows" => rows,
32
+ "row_count" => row_count,
33
+ "metadata" => metadata
34
+ }
35
+ end
36
+
37
+ private
38
+
39
+ def string(value, label)
40
+ return value.dup.freeze if value.is_a?(String) && !value.empty?
41
+
42
+ raise RemoteError, "Report result #{label} is invalid"
43
+ end
44
+
45
+ def kind_value(value)
46
+ return value.dup.freeze if Definition::KINDS.include?(value)
47
+
48
+ raise RemoteError, "Report result kind is invalid"
49
+ end
50
+
51
+ def normalize_headers(values, label)
52
+ unless values.is_a?(Array) && values.all? { |value| value.is_a?(String) && !value.empty? }
53
+ raise RemoteError, "Report result #{label} is invalid"
54
+ end
55
+
56
+ Canonical.immutable(values)
57
+ end
58
+
59
+ def validate_header_uniqueness!
60
+ raise RemoteError, "Report result headers are not unique" unless headers.uniq.length == headers.length
61
+ end
62
+
63
+ def normalized_rows(values)
64
+ raise RemoteError, "Report result rows must be an array" unless values.is_a?(Array)
65
+
66
+ normalized = values.map do |row|
67
+ value = hash(row, "row")
68
+ raise RemoteError, "Report result row fields do not match headers" unless value.keys.sort == headers.sort
69
+
70
+ value
71
+ end
72
+ Canonical.immutable(normalized)
73
+ end
74
+
75
+ def count(value)
76
+ return value if value.is_a?(Integer) && value >= rows.length
77
+
78
+ raise RemoteError, "Report result row_count is invalid"
79
+ end
80
+
81
+ def hash(value, label)
82
+ unless value.is_a?(Hash) && value.keys.all?(String)
83
+ raise RemoteError, "Report result #{label} must be an object with string keys"
84
+ end
85
+
86
+ Canonical.immutable(value)
87
+ end
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Reporting public namespace.
4
+
5
+ require_relative "reports/definition"
6
+ require_relative "reports/catalog"
7
+ require_relative "reports/result"
8
+
9
+ module AnalyticsOps
10
+ # Immutable Data API definitions, recipes, and normalized results.
11
+ module Reports
12
+ end
13
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Gem-owned values returned at public API boundaries.
4
+
5
+ module AnalyticsOps
6
+ module Resources
7
+ # Immutable base class for gem-owned values at the Google client boundary.
8
+ class Value
9
+ class << self
10
+ attr_reader :field_names
11
+
12
+ def fields(*names)
13
+ @field_names = names.freeze
14
+ attr_reader(*names)
15
+ end
16
+ end
17
+
18
+ def initialize(**values)
19
+ unknown = values.keys - self.class.field_names
20
+ missing = self.class.field_names - values.keys
21
+ raise ArgumentError, "Unknown fields: #{unknown.join(", ")}" unless unknown.empty?
22
+ raise ArgumentError, "Missing fields: #{missing.join(", ")}" unless missing.empty?
23
+
24
+ values.each do |name, value|
25
+ instance_variable_set("@#{name}", Canonical.immutable(value))
26
+ end
27
+ freeze
28
+ end
29
+
30
+ def to_h
31
+ self.class.field_names.to_h { |name| [name.to_s, public_send(name)] }
32
+ end
33
+
34
+ def ==(other)
35
+ other.instance_of?(self.class) && other.to_h == to_h
36
+ end
37
+ alias eql? ==
38
+
39
+ def hash
40
+ [self.class, to_h].hash
41
+ end
42
+ end
43
+
44
+ # Accessible Analytics account summary.
45
+ class Account < Value
46
+ fields :id, :name, :display_name, :properties
47
+ end
48
+
49
+ # Normalized GA4 property.
50
+ class Property < Value
51
+ fields :id, :name, :display_name, :parent, :property_type, :can_edit
52
+ end
53
+
54
+ # Normalized web or application data stream.
55
+ class DataStream < Value
56
+ fields :id, :name, :display_name, :type, :default_uri, :measurement_id
57
+ end
58
+
59
+ # Property retention settings.
60
+ class Retention < Value
61
+ fields :name, :event_data, :user_data, :reset_on_new_activity
62
+ end
63
+
64
+ # Registered key event.
65
+ class KeyEvent < Value
66
+ fields :name, :event_name, :counting_method
67
+ end
68
+
69
+ # Registered custom dimension.
70
+ class CustomDimension < Value
71
+ fields :name, :parameter_name, :display_name, :description, :scope, :disallow_ads_personalization
72
+ end
73
+
74
+ # Registered custom metric.
75
+ class CustomMetric < Value
76
+ fields :name, :parameter_name, :display_name, :description, :scope, :measurement_unit
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Normalized remote state.
4
+
5
+ module AnalyticsOps
6
+ # Normalized remote state. No generated Google objects escape into this value.
7
+ class Snapshot
8
+ attr_reader :property, :streams, :retention, :key_events, :custom_dimensions, :custom_metrics
9
+
10
+ def initialize(property:, streams:, retention:, key_events:, custom_dimensions:, custom_metrics:)
11
+ @property = property
12
+ @streams = sort(streams, &:id)
13
+ @retention = retention
14
+ @key_events = sort(key_events, &:event_name)
15
+ @custom_dimensions = sort(custom_dimensions) { |dimension| [dimension.scope, dimension.parameter_name] }
16
+ @custom_metrics = sort(custom_metrics, &:parameter_name)
17
+ freeze
18
+ end
19
+
20
+ def property_id
21
+ property.id
22
+ end
23
+
24
+ def to_h
25
+ {
26
+ "property" => property.to_h,
27
+ "streams" => streams.map(&:to_h),
28
+ "retention" => retention&.to_h,
29
+ "key_events" => key_events.map(&:to_h),
30
+ "custom_dimensions" => custom_dimensions.map(&:to_h),
31
+ "custom_metrics" => custom_metrics.map(&:to_h)
32
+ }
33
+ end
34
+
35
+ def fingerprint
36
+ Canonical.fingerprint(to_h)
37
+ end
38
+
39
+ private
40
+
41
+ def sort(values, &)
42
+ Canonical.deep_freeze(values.sort_by(&))
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AnalyticsOps
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,212 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Primary plain-Ruby API.
4
+
5
+ require "time"
6
+
7
+ module AnalyticsOps
8
+ # Primary Ruby API. Loading a workspace validates local data but performs no network calls.
9
+ class Workspace
10
+ # Immutable convergence result returned by verify.
11
+ class Verification
12
+ attr_reader :converged, :plan
13
+
14
+ def initialize(plan:)
15
+ @plan = plan
16
+ @converged = !plan.drift?
17
+ freeze
18
+ end
19
+
20
+ def to_h
21
+ { "converged" => converged, "plan" => plan.to_h }
22
+ end
23
+ end
24
+
25
+ # Immutable health-check collection returned by doctor.
26
+ class DoctorResult
27
+ attr_reader :checks
28
+
29
+ def initialize(checks:)
30
+ @checks = Canonical.deep_freeze(checks)
31
+ freeze
32
+ end
33
+
34
+ def success?
35
+ checks.none? { |check| check.fetch("status") == "error" }
36
+ end
37
+
38
+ def to_h
39
+ { "success" => success?, "checks" => checks }
40
+ end
41
+ end
42
+
43
+ attr_reader :desired_state
44
+
45
+ def self.load(path, profile:, environment: ENV, admin: nil, data: nil, credentials: nil,
46
+ transport: :grpc, timeout: nil, logger: nil)
47
+ document = Configuration.load(path, environment:)
48
+ new(desired_state: document.profile(profile), admin:, data:, credentials:, transport:, timeout:, logger:)
49
+ end
50
+
51
+ def initialize(desired_state:, admin: nil, data: nil, credentials: nil, transport: :grpc, timeout: nil, logger: nil)
52
+ @desired_state = desired_state
53
+ @admin = admin
54
+ @data = data
55
+ @credentials = credentials
56
+ @transport = transport
57
+ @timeout = timeout
58
+ @logger = logger
59
+ end
60
+
61
+ def discover
62
+ admin.discover
63
+ end
64
+
65
+ def snapshot
66
+ admin.snapshot(desired_state.property_id)
67
+ end
68
+
69
+ def audit
70
+ plan
71
+ end
72
+
73
+ def plan
74
+ Planner.new(desired_state:, snapshot:).call
75
+ end
76
+
77
+ def apply(plan_or_path, confirm: false)
78
+ saved_plan = plan_or_path.is_a?(Plan) ? plan_or_path : Plan.load(plan_or_path)
79
+ validate_plan_target!(saved_plan)
80
+ Applier.new(admin:).call(saved_plan, confirm:)
81
+ end
82
+
83
+ def verify
84
+ Verification.new(plan:)
85
+ end
86
+
87
+ def report(name_or_definition)
88
+ definition = report_definition(name_or_definition, kind: "standard")
89
+ data.run(desired_state.property_id, definition)
90
+ end
91
+
92
+ def realtime(name_or_definition = "realtime_events")
93
+ definition = report_definition(name_or_definition, kind: "realtime")
94
+ data.run(desired_state.property_id, definition)
95
+ end
96
+
97
+ def doctor
98
+ remote = snapshot
99
+ checks = [
100
+ { "name" => "configuration", "status" => "ok", "detail" => "Profile #{desired_state.profile} is valid" },
101
+ { "name" => "credentials", "status" => "ok", "detail" => "Google accepted discovered or injected credentials" },
102
+ { "name" => "admin_api", "status" => "ok", "detail" => "Admin API read succeeded" },
103
+ { "name" => "property_access", "status" => "ok", "detail" => "Read properties/#{remote.property_id}" }
104
+ ]
105
+ checks.concat(compatibility_checks)
106
+ checks << clock_check
107
+ checks << edit_capability_check
108
+ data.run(desired_state.property_id, doctor_report_definition)
109
+ checks << { "name" => "data_api", "status" => "ok", "detail" => "Data API read succeeded" }
110
+ checks << {
111
+ "name" => "oauth_scopes",
112
+ "status" => "unknown",
113
+ "detail" => "Installed Google clients do not expose a reliable token-scope inspection contract"
114
+ }
115
+ admin.capabilities.each do |name, available|
116
+ checks << {
117
+ "name" => name,
118
+ "status" => available ? "ok" : "unsupported",
119
+ "detail" => available ? "Installed client exposes this capability" : "Installed client lacks this capability"
120
+ }
121
+ end
122
+ DoctorResult.new(checks:)
123
+ end
124
+
125
+ private
126
+
127
+ def admin
128
+ @admin ||= Clients::Admin.new(
129
+ credentials: @credentials,
130
+ transport: @transport,
131
+ timeout: @timeout,
132
+ logger: @logger
133
+ )
134
+ end
135
+
136
+ def data
137
+ @data ||= Clients::Data.new(
138
+ credentials: @credentials,
139
+ transport: @transport,
140
+ timeout: @timeout,
141
+ logger: @logger
142
+ )
143
+ end
144
+
145
+ def compatibility_checks
146
+ [admin.compatibility, data.compatibility].map do |details|
147
+ {
148
+ "name" => details.fetch("package"),
149
+ "status" => details.fetch("supported") ? "ok" : "unsupported",
150
+ "detail" => "Installed #{details.fetch("version")}; supported #{details.fetch("requirement")}; " \
151
+ "transport #{details.fetch("transport")}"
152
+ }
153
+ end
154
+ end
155
+
156
+ def clock_check
157
+ now = Time.now.utc
158
+ plausible = now.year.between?(2024, 2100)
159
+ {
160
+ "name" => "local_clock",
161
+ "status" => plausible ? "ok" : "error",
162
+ "detail" => plausible ? "Local UTC clock is plausible: #{now.iso8601}" : "Local UTC clock is implausible"
163
+ }
164
+ end
165
+
166
+ def edit_capability_check
167
+ access = admin.property_access(desired_state.property_id)
168
+ case access.can_edit
169
+ when true
170
+ { "name" => "edit_capability", "status" => "ok", "detail" => "Account summary reports edit access" }
171
+ when false
172
+ { "name" => "edit_capability", "status" => "warning", "detail" => "Account summary reports read-only access" }
173
+ else
174
+ { "name" => "edit_capability", "status" => "unknown", "detail" => "Google did not report edit capability" }
175
+ end
176
+ end
177
+
178
+ def doctor_report_definition
179
+ Reports::Definition.new(
180
+ name: "doctor_connectivity",
181
+ kind: "standard",
182
+ dimensions: ["eventName"],
183
+ metrics: ["eventCount"],
184
+ date_ranges: [{ "start_date" => "yesterday", "end_date" => "yesterday" }],
185
+ limit: 1
186
+ )
187
+ end
188
+
189
+ def report_definition(name_or_definition, kind:)
190
+ definition = if name_or_definition.is_a?(Reports::Definition)
191
+ name_or_definition
192
+ else
193
+ Reports::Catalog.fetch(name_or_definition, kind:)
194
+ end
195
+ unless definition.kind == kind
196
+ raise InvalidRequestError, "Report #{definition.name} is #{definition.kind}, not #{kind}"
197
+ end
198
+
199
+ definition
200
+ end
201
+
202
+ def validate_plan_target!(saved_plan)
203
+ unless saved_plan.profile == desired_state.profile
204
+ raise InvalidPlanError,
205
+ "Plan profile #{saved_plan.profile.inspect} does not match #{desired_state.profile.inspect}"
206
+ end
207
+ return if saved_plan.property_id == desired_state.property_id
208
+
209
+ raise InvalidPlanError, "Plan property #{saved_plan.property_id} does not match configured property"
210
+ end
211
+ end
212
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "analytics_ops/version"
4
+ require_relative "analytics_ops/errors"
5
+ require_relative "analytics_ops/redaction"
6
+ require_relative "analytics_ops/canonical"
7
+ require_relative "analytics_ops/resources"
8
+ require_relative "analytics_ops/desired_state"
9
+ require_relative "analytics_ops/configuration"
10
+ require_relative "analytics_ops/snapshot"
11
+ require_relative "analytics_ops/plan"
12
+ require_relative "analytics_ops/planner"
13
+ require_relative "analytics_ops/clients/admin"
14
+ require_relative "analytics_ops/reports"
15
+ require_relative "analytics_ops/clients/data"
16
+ require_relative "analytics_ops/applier"
17
+ require_relative "analytics_ops/workspace"
18
+
19
+ # Safe Google Analytics 4 configuration and reporting operations.
20
+ module AnalyticsOps
21
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+
5
+ module AnalyticsOps
6
+ module Generators
7
+ # Installs only configuration and a project-local CLI launcher.
8
+ class InstallGenerator < Rails::Generators::Base
9
+ source_root File.expand_path("templates", __dir__)
10
+
11
+ desc "Create config/analytics_ops.yml and bin/analytics-ops"
12
+
13
+ def copy_configuration
14
+ template "analytics_ops.yml", "config/analytics_ops.yml"
15
+ end
16
+
17
+ def copy_executable
18
+ copy_file "analytics-ops", "bin/analytics-ops"
19
+ File.chmod(0o755, File.join(destination_root, "bin/analytics-ops"))
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,9 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__)
5
+
6
+ require "bundler/setup"
7
+ require "analytics_ops/cli"
8
+
9
+ exit AnalyticsOps::CLI.start(ARGV)
@@ -0,0 +1,23 @@
1
+ # Analytics Ops contains desired GA4 state only. Never place credentials here.
2
+ version: 1
3
+
4
+ profiles:
5
+ development:
6
+ # GA4 property ID (numeric), not an account ID, stream ID, or measurement ID.
7
+ property_id: "${GA4_PROPERTY_ID}"
8
+
9
+ streams:
10
+ web:
11
+ # Numeric stream ID from Admin > Data streams, not the G- measurement ID.
12
+ stream_id: "${GA4_STREAM_ID}"
13
+ default_uri: "https://example.test"
14
+
15
+ retention:
16
+ event_data: 14_months
17
+ user_data: 14_months
18
+ reset_on_new_activity: false
19
+
20
+ key_events: []
21
+ custom_dimensions: []
22
+ custom_metrics: []
23
+ manual_requirements: []