analytics_ops 0.2.0 → 0.3.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 (72) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +108 -0
  3. data/CONTRIBUTING.md +1 -0
  4. data/README.md +141 -13
  5. data/SECURITY.md +11 -2
  6. data/docs/ai-connections.md +192 -0
  7. data/docs/api-support-matrix.md +25 -8
  8. data/docs/architecture.md +78 -17
  9. data/docs/authentication.md +53 -3
  10. data/docs/bigquery.md +128 -0
  11. data/docs/commands.md +133 -12
  12. data/docs/configuration-schema-v1.json +608 -50
  13. data/docs/configuration.md +118 -5
  14. data/docs/google-client-compatibility.md +21 -1
  15. data/docs/governance.md +93 -0
  16. data/docs/health.md +82 -0
  17. data/docs/live-smoke-test.md +13 -10
  18. data/docs/rails.md +25 -9
  19. data/docs/reports.md +165 -3
  20. data/docs/safety.md +37 -5
  21. data/docs/troubleshooting.md +81 -7
  22. data/lib/analytics_ops/bigquery/definition.rb +173 -0
  23. data/lib/analytics_ops/bigquery.rb +4 -0
  24. data/lib/analytics_ops/cli/local_commands.rb +80 -0
  25. data/lib/analytics_ops/cli/options.rb +282 -9
  26. data/lib/analytics_ops/cli/presenter.rb +263 -28
  27. data/lib/analytics_ops/cli/presenter_csv.rb +92 -0
  28. data/lib/analytics_ops/cli/reporting_commands.rb +129 -0
  29. data/lib/analytics_ops/cli/setup_commands.rb +91 -0
  30. data/lib/analytics_ops/cli.rb +121 -44
  31. data/lib/analytics_ops/clients/admin.rb +28 -2
  32. data/lib/analytics_ops/clients/admin_governance.rb +95 -0
  33. data/lib/analytics_ops/clients/admin_governance_normalization.rb +90 -0
  34. data/lib/analytics_ops/clients/admin_resource_normalization.rb +61 -0
  35. data/lib/analytics_ops/clients/bigquery.rb +313 -0
  36. data/lib/analytics_ops/clients/data.rb +147 -54
  37. data/lib/analytics_ops/clients/data_result_normalization.rb +55 -0
  38. data/lib/analytics_ops/clients/error_translation.rb +13 -1
  39. data/lib/analytics_ops/configuration/schema.rb +175 -1
  40. data/lib/analytics_ops/configuration/validator.rb +45 -7
  41. data/lib/analytics_ops/configuration/validator_experimental.rb +136 -0
  42. data/lib/analytics_ops/configuration/validator_reporting.rb +93 -0
  43. data/lib/analytics_ops/configuration/writer.rb +112 -7
  44. data/lib/analytics_ops/desired_state.rb +28 -3
  45. data/lib/analytics_ops/errors.rb +33 -1
  46. data/lib/analytics_ops/funnels.rb +58 -0
  47. data/lib/analytics_ops/governance.rb +363 -0
  48. data/lib/analytics_ops/health.rb +310 -0
  49. data/lib/analytics_ops/mcp_server/custom_report_tool.rb +101 -0
  50. data/lib/analytics_ops/mcp_server/discovery_tools.rb +79 -0
  51. data/lib/analytics_ops/mcp_server/experimental_tools.rb +89 -0
  52. data/lib/analytics_ops/mcp_server/health_tools.rb +80 -0
  53. data/lib/analytics_ops/mcp_server/overview_tools.rb +49 -0
  54. data/lib/analytics_ops/mcp_server/standard_report_tool.rb +53 -0
  55. data/lib/analytics_ops/mcp_server.rb +402 -0
  56. data/lib/analytics_ops/portfolio.rb +216 -0
  57. data/lib/analytics_ops/rails/railtie.rb +19 -8
  58. data/lib/analytics_ops/redaction.rb +10 -6
  59. data/lib/analytics_ops/reports/csv_export.rb +91 -0
  60. data/lib/analytics_ops/reports/definition.rb +152 -14
  61. data/lib/analytics_ops/reports/metadata.rb +158 -0
  62. data/lib/analytics_ops/reports/period.rb +104 -0
  63. data/lib/analytics_ops/reports/result.rb +8 -3
  64. data/lib/analytics_ops/reports.rb +3 -0
  65. data/lib/analytics_ops/service_account.rb +321 -29
  66. data/lib/analytics_ops/setup.rb +31 -7
  67. data/lib/analytics_ops/version.rb +1 -1
  68. data/lib/analytics_ops/workspace.rb +222 -10
  69. data/lib/analytics_ops.rb +7 -2
  70. data/lib/generators/analytics_ops/templates/analytics_ops.yml +1 -1
  71. data/sig/analytics_ops.rbs +494 -9
  72. metadata +60 -1
@@ -7,6 +7,8 @@ require "time"
7
7
  module AnalyticsOps
8
8
  # Primary Ruby API. Loading a workspace validates local data but performs no network calls.
9
9
  class Workspace
10
+ MAX_EXPORT_ROWS = 1_000_000
11
+ DEFAULT_PAGE_SIZE = 100_000
10
12
  # Immutable convergence result returned by verify.
11
13
  class Verification
12
14
  attr_reader :converged, :plan
@@ -42,13 +44,22 @@ module AnalyticsOps
42
44
 
43
45
  attr_reader :desired_state
44
46
 
45
- def self.load(path, profile:, environment: ENV, admin: nil, data: nil, service_account: nil,
47
+ def self.load(path, profile:, environment: ENV, admin: nil, data: nil, bigquery: nil, service_account: nil,
46
48
  transport: :grpc, timeout: nil, logger: nil)
47
49
  document = Configuration.load(path, environment:)
48
- new(desired_state: document.profile(profile), admin:, data:, service_account:, transport:, timeout:, logger:)
50
+ new(
51
+ desired_state: document.profile(profile),
52
+ admin:,
53
+ data:,
54
+ bigquery:,
55
+ service_account:,
56
+ transport:,
57
+ timeout:,
58
+ logger:
59
+ )
49
60
  end
50
61
 
51
- def initialize(desired_state:, admin: nil, data: nil, service_account: nil,
62
+ def initialize(desired_state:, admin: nil, data: nil, bigquery: nil, service_account: nil,
52
63
  transport: :grpc, timeout: nil, logger: nil)
53
64
  unless service_account.nil? || service_account.is_a?(ServiceAccount)
54
65
  raise ConfigurationError, "service_account must be an AnalyticsOps::ServiceAccount"
@@ -58,6 +69,7 @@ module AnalyticsOps
58
69
  @admin = admin
59
70
  @injected_admin = admin
60
71
  @data = data
72
+ @bigquery = bigquery
61
73
  @service_account = service_account
62
74
  @transport = transport
63
75
  @timeout = timeout
@@ -90,21 +102,150 @@ module AnalyticsOps
90
102
  Verification.new(plan:)
91
103
  end
92
104
 
93
- def report(name_or_definition)
94
- definition = report_definition(name_or_definition, kind: "standard")
105
+ def report(name_or_definition, date_ranges: nil)
106
+ definition, custom = resolved_report_definition(name_or_definition, kind: "standard")
107
+ definition = definition.with_date_ranges(date_ranges) if date_ranges
108
+ validate_report_definition(definition) if custom
95
109
  data.run(desired_state.property_id, definition)
96
110
  end
97
111
 
98
112
  def realtime(name_or_definition = "realtime_events")
99
- definition = report_definition(name_or_definition, kind: "realtime")
113
+ definition, custom = resolved_report_definition(name_or_definition, kind: "realtime")
114
+ validate_report_definition(definition) if custom
100
115
  data.run(desired_state.property_id, definition)
101
116
  end
102
117
 
103
- def overview
104
- reports = data.batch(desired_state.property_id, Reports::Catalog.overview)
118
+ def report_metadata(query: nil, kind: nil)
119
+ fields = data.metadata(desired_state.property_id).search(query, kind:)
120
+ Reports::FieldList.new(property_id: desired_state.property_id, query:, kind: kind&.to_s, fields:)
121
+ end
122
+
123
+ def report_recipes
124
+ built_in = Reports::Catalog.names.map do |name|
125
+ definition = Reports::Catalog.fetch(name)
126
+ Reports::Recipe.new(
127
+ name:,
128
+ kind: definition.kind,
129
+ source: "built_in",
130
+ dimensions: definition.dimensions,
131
+ metrics: definition.metrics
132
+ )
133
+ end
134
+ configured = desired_state.reports.map do |name, definition|
135
+ Reports::Recipe.new(
136
+ name:,
137
+ kind: definition.kind,
138
+ source: "configuration",
139
+ dimensions: definition.dimensions,
140
+ metrics: definition.metrics
141
+ )
142
+ end
143
+ Reports::RecipeList.new(recipes: (built_in + configured).sort_by { |recipe| [recipe.name, recipe.source] })
144
+ end
145
+
146
+ def report_compatibility(name_or_definition)
147
+ definition, = resolved_report_definition(name_or_definition, kind: "standard")
148
+ validate_report_fields!(definition)
149
+ data.check_compatibility(desired_state.property_id, definition)
150
+ end
151
+
152
+ def each_report_page(name_or_definition, date_ranges: nil, page_size: DEFAULT_PAGE_SIZE,
153
+ max_rows: MAX_EXPORT_ROWS)
154
+ return enum_for(__method__, name_or_definition, date_ranges:, page_size:, max_rows:) unless block_given?
155
+
156
+ definition, custom = resolved_report_definition(name_or_definition, kind: "standard")
157
+ definition = definition.with_date_ranges(date_ranges) if date_ranges
158
+ validate_report_definition(definition) if custom
159
+ page_size = bounded_integer(page_size, "page_size", maximum: Reports::Definition::MAX_STANDARD_LIMIT)
160
+ max_rows = bounded_integer(max_rows, "max_rows", maximum: MAX_EXPORT_ROWS)
161
+
162
+ offset = definition.offset
163
+ ending_offset = offset + max_rows
164
+ while offset < ending_offset
165
+ requested = [page_size, ending_offset - offset].min
166
+ result = data.run(
167
+ desired_state.property_id,
168
+ definition.with_pagination(offset:, limit: requested)
169
+ )
170
+ yield result
171
+ returned = result.rows.length
172
+ break if returned.zero? || offset + returned >= result.row_count
173
+
174
+ offset += returned
175
+ end
176
+ end
177
+
178
+ def export_report(name_or_definition, path:, date_ranges: nil, page_size: DEFAULT_PAGE_SIZE,
179
+ max_rows: MAX_EXPORT_ROWS)
180
+ pages = each_report_page(name_or_definition, date_ranges:, page_size:, max_rows:)
181
+ Reports::CSVExport.write(path, pages)
182
+ end
183
+
184
+ def overview(date_ranges: nil)
185
+ definitions = Reports::Catalog.overview
186
+ definitions = definitions.map { |definition| definition.with_date_ranges(date_ranges) }.freeze if date_ranges
187
+ reports = data.batch(desired_state.property_id, definitions)
105
188
  Reports::OverviewResult.new(property_id: desired_state.property_id, reports:)
106
189
  end
107
190
 
191
+ def health(date_ranges: nil)
192
+ ranges = date_ranges || Reports::Period.resolve(last_days: Reports::Period::DEFAULT_DAYS, compare: true)
193
+ Health.validate_date_ranges!(ranges)
194
+ definitions = Health::Definitions.for(ranges)
195
+ reports = data.batch(desired_state.property_id, definitions)
196
+ current_snapshot = snapshot
197
+ current_plan = Planner.new(desired_state:, snapshot: current_snapshot).call
198
+ Health::Runner.new(
199
+ desired_state:,
200
+ reports:,
201
+ plan: current_plan,
202
+ date_ranges: ranges
203
+ ).call
204
+ end
205
+
206
+ def governance
207
+ remote = admin.governance_snapshot(desired_state.property_id)
208
+ Governance::Auditor.new(snapshot: remote, settings: desired_state.governance).call
209
+ end
210
+
211
+ def change_history(limit: 100, days: 30)
212
+ edit_admin.change_history(desired_state.property_id, limit:, days:)
213
+ end
214
+
215
+ def access_report(date_ranges: nil, limit: 1_000)
216
+ ranges = date_ranges || Reports::Period.resolve(last_days: Reports::Period::DEFAULT_DAYS, compare: false)
217
+ admin.access_report(desired_state.property_id, date_ranges: ranges, limit:)
218
+ end
219
+
220
+ def raw_report(name, date_ranges: nil, limit: 1_000)
221
+ settings = desired_state.bigquery
222
+ unless settings
223
+ raise ConfigurationError,
224
+ "BigQuery is not configured for this profile; see docs/bigquery.md"
225
+ end
226
+ ranges = date_ranges || Reports::Period.resolve(last_days: Reports::Period::DEFAULT_DAYS, compare: false)
227
+ definition = BigQuery::Definition.from_date_ranges(name, ranges, limit:)
228
+ bigquery_client.run(definition)
229
+ end
230
+
231
+ def funnel(name, date_ranges: nil)
232
+ definition = desired_state.funnels.fetch(name.to_s) do
233
+ available = desired_state.funnels.keys.sort.join(", ")
234
+ raise InvalidRequestError, "Unknown funnel #{name.inspect}; available funnels: #{available}"
235
+ end
236
+ unless desired_state.bigquery
237
+ raise UnsupportedCapabilityError,
238
+ "Funnel reports currently require the optional BigQuery configuration"
239
+ end
240
+ ranges = date_ranges || Reports::Period.resolve(last_days: Reports::Period::DEFAULT_DAYS, compare: false)
241
+ dates = BigQuery::Definition.from_date_ranges("event_counts", ranges, limit: 1)
242
+ bigquery_client.run_funnel(
243
+ definition,
244
+ start_suffix: dates.start_suffix,
245
+ end_suffix: dates.end_suffix
246
+ )
247
+ end
248
+
108
249
  def doctor
109
250
  remote = snapshot
110
251
  checks = [
@@ -114,6 +255,7 @@ module AnalyticsOps
114
255
  { "name" => "admin_api", "status" => "ok", "detail" => "Admin API read succeeded" },
115
256
  { "name" => "property_access", "status" => "ok", "detail" => "Read properties/#{remote.property_id}" }
116
257
  ]
258
+ checks.concat(credential_file_checks)
117
259
  checks.concat(compatibility_checks)
118
260
  checks << clock_check
119
261
  checks << edit_capability_check
@@ -167,6 +309,18 @@ module AnalyticsOps
167
309
  )
168
310
  end
169
311
 
312
+ def bigquery_client
313
+ return @bigquery if @bigquery
314
+
315
+ require_relative "bigquery"
316
+ @bigquery = Clients::BigQuery.new(
317
+ settings: desired_state.bigquery,
318
+ service_account:,
319
+ timeout: @timeout,
320
+ logger: @logger
321
+ )
322
+ end
323
+
170
324
  def service_account
171
325
  @service_account ||= ServiceAccount.load
172
326
  end
@@ -182,6 +336,27 @@ module AnalyticsOps
182
336
  end
183
337
  end
184
338
 
339
+ def credential_file_checks
340
+ return [] unless @service_account
341
+
342
+ warnings = @service_account.security_warnings
343
+ if warnings.empty?
344
+ return [{
345
+ "name" => "credential_file_security",
346
+ "status" => "ok",
347
+ "detail" => "Service-account key permissions and storage location look safe"
348
+ }]
349
+ end
350
+
351
+ warnings.each_with_index.map do |warning, index|
352
+ {
353
+ "name" => "credential_file_security_#{index + 1}",
354
+ "status" => "warning",
355
+ "detail" => warning
356
+ }
357
+ end
358
+ end
359
+
185
360
  def clock_check
186
361
  now = Time.now.utc
187
362
  plausible = now.year.between?(2024, 2100)
@@ -215,9 +390,14 @@ module AnalyticsOps
215
390
  )
216
391
  end
217
392
 
218
- def report_definition(name_or_definition, kind:)
393
+ def resolved_report_definition(name_or_definition, kind:)
394
+ custom = false
219
395
  definition = if name_or_definition.is_a?(Reports::Definition)
396
+ custom = true
220
397
  name_or_definition
398
+ elsif desired_state.reports.key?(name_or_definition.to_s)
399
+ custom = true
400
+ desired_state.reports.fetch(name_or_definition.to_s)
221
401
  else
222
402
  Reports::Catalog.fetch(name_or_definition, kind:)
223
403
  end
@@ -225,7 +405,39 @@ module AnalyticsOps
225
405
  raise InvalidRequestError, "Report #{definition.name} is #{definition.kind}, not #{kind}"
226
406
  end
227
407
 
228
- definition
408
+ [definition, custom]
409
+ end
410
+
411
+ def validate_report_definition(definition)
412
+ validate_report_fields!(definition)
413
+ return if definition.realtime?
414
+
415
+ compatibility = data.check_compatibility(desired_state.property_id, definition)
416
+ return if compatibility.compatible
417
+
418
+ incompatible = (compatibility.dimensions + compatibility.metrics).reject do |entry|
419
+ entry.fetch("compatibility") == "compatible"
420
+ end
421
+ names = incompatible.map { |entry| entry.fetch("api_name") }.join(", ")
422
+ raise InvalidRequestError, "Report fields are not compatible for this property: #{names}"
423
+ end
424
+
425
+ def validate_report_fields!(definition)
426
+ metadata = data.metadata(desired_state.property_id)
427
+ available_dimensions = metadata.dimensions.map(&:api_name)
428
+ available_metrics = metadata.metrics.map(&:api_name)
429
+ missing_dimensions = definition.dimensions - available_dimensions
430
+ missing_metrics = definition.metrics - available_metrics
431
+ return if missing_dimensions.empty? && missing_metrics.empty?
432
+
433
+ missing = (missing_dimensions + missing_metrics).join(", ")
434
+ raise InvalidRequestError, "Unknown report fields for this property: #{missing}"
435
+ end
436
+
437
+ def bounded_integer(value, label, maximum:)
438
+ return value if value.is_a?(Integer) && value.between?(1, maximum)
439
+
440
+ raise InvalidRequestError, "#{label} must be between 1 and #{maximum}"
229
441
  end
230
442
 
231
443
  def validate_plan_target!(saved_plan)
data/lib/analytics_ops.rb CHANGED
@@ -1,11 +1,15 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "analytics_ops/version"
4
- require_relative "analytics_ops/errors"
5
4
  require_relative "analytics_ops/redaction"
5
+ require_relative "analytics_ops/errors"
6
6
  require_relative "analytics_ops/service_account"
7
7
  require_relative "analytics_ops/canonical"
8
8
  require_relative "analytics_ops/resources"
9
+ require_relative "analytics_ops/reports"
10
+ require_relative "analytics_ops/bigquery/definition"
11
+ require_relative "analytics_ops/funnels"
12
+ require_relative "analytics_ops/governance"
9
13
  require_relative "analytics_ops/desired_state"
10
14
  require_relative "analytics_ops/configuration"
11
15
  require_relative "analytics_ops/snapshot"
@@ -13,12 +17,13 @@ require_relative "analytics_ops/plan"
13
17
  require_relative "analytics_ops/planner"
14
18
  require_relative "analytics_ops/clients/error_translation"
15
19
  require_relative "analytics_ops/clients/admin"
16
- require_relative "analytics_ops/reports"
17
20
  require_relative "analytics_ops/clients/data"
21
+ require_relative "analytics_ops/health"
18
22
  require_relative "analytics_ops/connection"
19
23
  require_relative "analytics_ops/setup"
20
24
  require_relative "analytics_ops/applier"
21
25
  require_relative "analytics_ops/workspace"
26
+ require_relative "analytics_ops/portfolio"
22
27
 
23
28
  # Safe Google Analytics 4 configuration and reporting operations.
24
29
  module AnalyticsOps
@@ -2,7 +2,7 @@
2
2
  version: 1
3
3
 
4
4
  profiles:
5
- development:
5
+ production:
6
6
  # GA4 property ID (numeric), not an account ID, stream ID, or measurement ID.
7
7
  property_id: "${GA4_PROPERTY_ID}"
8
8